feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力

This commit is contained in:
2026-09-08 12:23:20 +08:00
parent f4aeeef49b
commit 4c79e940d2
59 changed files with 4242 additions and 102 deletions
+1079 -17
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -28,6 +28,19 @@ tauri = { version = "2", optional = true, features = ["tray-icon"] }
rfd = { version = "0.15", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
base64 = { version = "0.22", optional = true }
hmac = { version = "0.12", default-features = false }
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
zeroize = { version = "1", default-features = false, features = ["alloc"] }
command-group = { version = "5", default-features = false }
iota_stronghold = "2.1"
argon2 = "0.5"
chacha20poly1305 = "0.10"
fernet = { version = "0.2", default-features = false, features = ["rustcrypto"] }
[build-dependencies]
tauri-build = { version = "2", optional = true , features = [] }
# Cryptographic KDFs retain their production work factors in debug/test runs.
# Optimize dependencies rather than weakening those factors for local execution.
[profile.dev.package."*"]
opt-level = 2
+20
View File
@@ -1,9 +1,29 @@
fn main() {
let manifest = std::path::Path::new("../../.build/sidecar/manifest.json");
println!("cargo:rerun-if-changed={}", manifest.display());
let content = if std::env::var("PROFILE").as_deref() == Ok("release") {
std::fs::read(manifest)
.expect("Build Core with scripts/build-core.py before a release Host")
} else {
b"{}".to_vec()
};
std::fs::write(
std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("core-manifest.json"),
content,
)
.unwrap();
#[cfg(feature = "desktop")]
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
tauri_build::AppManifest::new().commands(&[
"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",
@@ -8,7 +8,14 @@
"permissions": [
"core:default",
"allow-host-capabilities",
"allow-credentials-status",
"allow-credentials-unlock",
"allow-credentials-lock",
"allow-credentials-change-password",
"allow-credentials-import",
"allow-core-request",
"allow-core-stream",
"allow-core-stream-cancel",
"allow-workspace-choose",
"allow-workspace-open",
"allow-workspace-tree",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-core-stream"
description = "Enables the core_stream command without any pre-configured scope."
commands.allow = ["core_stream"]
[[permission]]
identifier = "deny-core-stream"
description = "Denies the core_stream command without any pre-configured scope."
commands.deny = ["core_stream"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-core-stream-cancel"
description = "Enables the core_stream_cancel command without any pre-configured scope."
commands.allow = ["core_stream_cancel"]
[[permission]]
identifier = "deny-core-stream-cancel"
description = "Denies the core_stream_cancel command without any pre-configured scope."
commands.deny = ["core_stream_cancel"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-change-password"
description = "Enables the credentials_change_password command without any pre-configured scope."
commands.allow = ["credentials_change_password"]
[[permission]]
identifier = "deny-credentials-change-password"
description = "Denies the credentials_change_password command without any pre-configured scope."
commands.deny = ["credentials_change_password"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-import"
description = "Enables the credentials_import command without any pre-configured scope."
commands.allow = ["credentials_import"]
[[permission]]
identifier = "deny-credentials-import"
description = "Denies the credentials_import command without any pre-configured scope."
commands.deny = ["credentials_import"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-lock"
description = "Enables the credentials_lock command without any pre-configured scope."
commands.allow = ["credentials_lock"]
[[permission]]
identifier = "deny-credentials-lock"
description = "Denies the credentials_lock command without any pre-configured scope."
commands.deny = ["credentials_lock"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-status"
description = "Enables the credentials_status command without any pre-configured scope."
commands.allow = ["credentials_status"]
[[permission]]
identifier = "deny-credentials-status"
description = "Denies the credentials_status command without any pre-configured scope."
commands.deny = ["credentials_status"]
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-unlock"
description = "Enables the credentials_unlock command without any pre-configured scope."
commands.allow = ["credentials_unlock"]
[[permission]]
identifier = "deny-credentials-unlock"
description = "Denies the credentials_unlock command without any pre-configured scope."
commands.deny = ["credentials_unlock"]
+471
View File
@@ -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());
}
}
+710
View File
@@ -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");
}
}
+3
View File
@@ -1,4 +1,7 @@
//! 原生文件所有权与持久化 outbox;本库不依赖 WebView,可独立执行破坏性故障测试。
pub mod core;
pub mod credentials;
pub mod recent;
mod runtime_compat;
pub mod workspace;
+329 -21
View File
@@ -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();
}
}
});
}
+39
View File
@@ -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));
}
}
@@ -0,0 +1,7 @@
{
"bundle": {
"active": true,
"targets": ["nsis"],
"resources": {"../../.build/sidecar/dist/opennexus-core/": "core/"}
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "NotesAgent Preview",
"productName": "OpenNexus",
"version": "0.3.0-alpha.1",
"identifier": "cc.kronecker.notesagent",
"build": {
@@ -10,9 +10,9 @@
"frontendDist": "../dist"
},
"app": {
"windows": [{"label": "main", "title": "NotesAgent Preview", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
"windows": [{"label": "main", "title": "OpenNexus", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}],
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:8000; font-src 'self' data:; connect-src ipc: http://ipc.localhost http://127.0.0.1:8000; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"capabilities": ["main"]
}
},
+21
View File
@@ -0,0 +1,21 @@
use notesagent_host::core::verify_bundle;
#[test]
fn bundle_rejects_modified_missing_and_extra_files() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("opennexus-core.exe");
std::fs::write(&file, b"abc").unwrap();
let manifest = r#"{"protocol":1,"product":"OpenNexus","files":{"opennexus-core.exe":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}}"#;
verify_bundle(dir.path(), manifest).unwrap();
std::fs::write(&file, b"abd").unwrap();
assert_eq!(
verify_bundle(dir.path(), manifest).unwrap_err(),
"CORE_INTEGRITY_FAILED"
);
std::fs::write(&file, b"abc").unwrap();
std::fs::write(dir.path().join("injected.dll"), b"malicious").unwrap();
assert!(verify_bundle(dir.path(), manifest).is_err());
std::fs::remove_file(dir.path().join("injected.dll")).unwrap();
std::fs::remove_file(file).unwrap();
assert!(verify_bundle(dir.path(), manifest).is_err());
}
+123
View File
@@ -0,0 +1,123 @@
//! Executes the real worktree Core, with no personal data or external Provider.
use notesagent_host::core::CoreSupervisor;
use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope};
use std::path::Path;
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
#[test]
fn real_python_core_authenticates_and_rotates_generation() {
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../backend")
.canonicalize()
.unwrap();
let python = backend.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
assert!(
python.is_file(),
"Create the isolated backend environment with uv sync --frozen"
);
let temp = tempfile::tempdir().unwrap();
let mut first = CoreSupervisor::new(
python.clone(),
vec!["-m".into(), "app.sidecar".into()],
backend.clone(),
temp.path().join("one"),
);
let request = first.request_session("/health").unwrap();
assert!(first.available());
assert!(request.url.starts_with("http://127.0.0.1:"));
assert!(!request.url.contains(request.authorization.as_str()));
let mut second = CoreSupervisor::new(
python,
vec!["-m".into(), "app.sidecar".into()],
backend,
temp.path().join("two"),
);
let other = second.request_session("/health").unwrap();
assert_ne!(request.generation, other.generation);
assert_ne!(request.authorization.as_str(), other.authorization.as_str());
drop(first);
let endpoint = request
.url
.trim_start_matches("http://")
.trim_end_matches("/health");
assert!(std::net::TcpStream::connect(endpoint).is_err());
}
#[test]
fn real_core_credential_api_uses_host_stronghold_without_plaintext_response() {
use std::io::{Read, Write};
let backend = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../backend")
.canonicalize()
.unwrap();
let python = backend.join(if cfg!(windows) {
".venv/Scripts/python.exe"
} else {
".venv/bin/python"
});
let temp = tempfile::tempdir().unwrap();
let broker = Arc::new(Mutex::new(CredentialBroker::new(
temp.path().join("stronghold.v1"),
)));
broker
.lock()
.unwrap()
.unlock(Zeroizing::new(b"controlled-fixture-password".to_vec()))
.unwrap();
let handler = broker.clone();
let mut core = CoreSupervisor::new(
python,
vec!["-m".into(), "app.sidecar".into()],
backend,
temp.path().join("core"),
)
.with_broker(Arc::new(move |request| {
handler.lock().unwrap().dispatch(request)
}));
let session = core
.request_session("/api/credentials/fixture-provider")
.unwrap();
let endpoint = session
.url
.trim_start_matches("http://")
.split('/')
.next()
.unwrap();
let body = r#"{"api_key":"fixture-credential-via-host-pipe"}"#;
let mut socket = std::net::TcpStream::connect(endpoint).unwrap();
socket
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.unwrap();
write!(socket, "PUT /api/credentials/fixture-provider HTTP/1.1\r\nHost: {endpoint}\r\nAuthorization: {}\r\nX-Core-Generation: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", session.authorization.as_str(), session.generation, body.len()).unwrap();
let mut response = String::new();
socket.read_to_string(&mut response).unwrap();
assert!(
response.starts_with("HTTP/1.1 200"),
"credential API did not succeed"
);
assert!(!response.contains("fixture-credential-via-host-pipe"));
let id = CredentialId {
scope: Scope::Provider,
id: "fixture-provider".into(),
};
assert_eq!(
broker
.lock()
.unwrap()
.resolve(&Scope::Provider, &id)
.unwrap()
.as_deref()
.map(|s| s.as_slice()),
Some(b"fixture-credential-via-host-pipe".as_slice())
);
assert!(!temp.path().join("core/credentials/master.key").exists());
assert!(!temp
.path()
.join("core/credentials/credentials.json")
.exists());
}
+208
View File
@@ -0,0 +1,208 @@
{
"test_only": true,
"key": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
"values": {
"provider-000": "fixture-secret-000",
"provider-001": "fixture-secret-001",
"provider-002": "fixture-secret-002",
"provider-003": "fixture-secret-003",
"provider-004": "fixture-secret-004",
"provider-005": "fixture-secret-005",
"provider-006": "fixture-secret-006",
"provider-007": "fixture-secret-007",
"provider-008": "fixture-secret-008",
"provider-009": "fixture-secret-009",
"provider-010": "fixture-secret-010",
"provider-011": "fixture-secret-011",
"provider-012": "fixture-secret-012",
"provider-013": "fixture-secret-013",
"provider-014": "fixture-secret-014",
"provider-015": "fixture-secret-015",
"provider-016": "fixture-secret-016",
"provider-017": "fixture-secret-017",
"provider-018": "fixture-secret-018",
"provider-019": "fixture-secret-019",
"provider-020": "fixture-secret-020",
"provider-021": "fixture-secret-021",
"provider-022": "fixture-secret-022",
"provider-023": "fixture-secret-023",
"provider-024": "fixture-secret-024",
"provider-025": "fixture-secret-025",
"provider-026": "fixture-secret-026",
"provider-027": "fixture-secret-027",
"provider-028": "fixture-secret-028",
"provider-029": "fixture-secret-029",
"provider-030": "fixture-secret-030",
"provider-031": "fixture-secret-031",
"provider-032": "fixture-secret-032",
"provider-033": "fixture-secret-033",
"provider-034": "fixture-secret-034",
"provider-035": "fixture-secret-035",
"provider-036": "fixture-secret-036",
"provider-037": "fixture-secret-037",
"provider-038": "fixture-secret-038",
"provider-039": "fixture-secret-039",
"provider-040": "fixture-secret-040",
"provider-041": "fixture-secret-041",
"provider-042": "fixture-secret-042",
"provider-043": "fixture-secret-043",
"provider-044": "fixture-secret-044",
"provider-045": "fixture-secret-045",
"provider-046": "fixture-secret-046",
"provider-047": "fixture-secret-047",
"provider-048": "fixture-secret-048",
"provider-049": "fixture-secret-049",
"provider-050": "fixture-secret-050",
"provider-051": "fixture-secret-051",
"provider-052": "fixture-secret-052",
"provider-053": "fixture-secret-053",
"provider-054": "fixture-secret-054",
"provider-055": "fixture-secret-055",
"provider-056": "fixture-secret-056",
"provider-057": "fixture-secret-057",
"provider-058": "fixture-secret-058",
"provider-059": "fixture-secret-059",
"provider-060": "fixture-secret-060",
"provider-061": "fixture-secret-061",
"provider-062": "fixture-secret-062",
"provider-063": "fixture-secret-063",
"provider-064": "fixture-secret-064",
"provider-065": "fixture-secret-065",
"provider-066": "fixture-secret-066",
"provider-067": "fixture-secret-067",
"provider-068": "fixture-secret-068",
"provider-069": "fixture-secret-069",
"provider-070": "fixture-secret-070",
"provider-071": "fixture-secret-071",
"provider-072": "fixture-secret-072",
"provider-073": "fixture-secret-073",
"provider-074": "fixture-secret-074",
"provider-075": "fixture-secret-075",
"provider-076": "fixture-secret-076",
"provider-077": "fixture-secret-077",
"provider-078": "fixture-secret-078",
"provider-079": "fixture-secret-079",
"provider-080": "fixture-secret-080",
"provider-081": "fixture-secret-081",
"provider-082": "fixture-secret-082",
"provider-083": "fixture-secret-083",
"provider-084": "fixture-secret-084",
"provider-085": "fixture-secret-085",
"provider-086": "fixture-secret-086",
"provider-087": "fixture-secret-087",
"provider-088": "fixture-secret-088",
"provider-089": "fixture-secret-089",
"provider-090": "fixture-secret-090",
"provider-091": "fixture-secret-091",
"provider-092": "fixture-secret-092",
"provider-093": "fixture-secret-093",
"provider-094": "fixture-secret-094",
"provider-095": "fixture-secret-095",
"provider-096": "fixture-secret-096",
"provider-097": "fixture-secret-097",
"provider-098": "fixture-secret-098",
"provider-099": "fixture-secret-099"
},
"tokens": {
"provider-000": "gAAAAAAAD0JARqrDNs-yq83rOzN4Fr-VBvZUACnn102cROns5fJFMGCz2gV-CeZmVVD-Z3SRXFszpHy9GeF6VYqh6YIuT74GuLcs3OXhZbsjnHQLpSgFSEQ=",
"provider-001": "gAAAAAAAD0JAcVlmcEu1YtpyqZwpyVFDekfujUGGX9UvwikB9b5S1fJsFxkREFqvqCkriMyx1fBVVnBksgIkeQs_3wAiItJJ-85u-bI3s3tYNwwbRP3WJc8=",
"provider-002": "gAAAAAAAD0JAfzv-Gsa9LS-TsbJlGu85an8pvoelczxr_4ClBIOfAbb4hnp-BJFOrWrkUFIrdcUYVTNAFMAnCyHkBjk24qf9aHHRAIVhfhY1MOrHlEgmZVc=",
"provider-003": "gAAAAAAAD0JAhTIDM4rkBwD1THGSiV_dDcSypkhB9zpivxbtr8m5Pa2EQ5yZ_aZza28fYPIJctUYYqsp-g39F_p0zmqdMcdMDdEBu-5Rkk98VffMSxVNWX0=",
"provider-004": "gAAAAAAAD0JAL-SAKnLr5dP1fRGIBAheTO2ZmsSMSMzXGrkClU21EAAtr5PKbH00i2r5PjpRpt8419bI_sH16flA0N9Tsc-kXp1mNhCMJa8V5G04bMPG2vY=",
"provider-005": "gAAAAAAAD0JAILcE3HJND9p7zM2Zaar4wZyg1EJL53-qQ_mfmqB4fRGvtGwSBemeAo2jdMwzovmYcA7hWwZ4XYvpkNZgLQYAzDrdwFw4yqBR98iyVO1a72c=",
"provider-006": "gAAAAAAAD0JABOivDjaiCbjTCzY2K6QHqi1n0P3QRiBGzwQt2eutxv5sR1kyYYE4XI3LpI04AplSr6gY7KsXsbz-6pMQrafNFHvlBI8kVCe0MwgsbbBHjyY=",
"provider-007": "gAAAAAAAD0JAgjSooEXGu-V4mZCaxFuNMysTkuLcA54T7HO9lEFQmkJTzCPUjx_THiz5zMciilbPknCaD2HVOx5CSO2DJqXlgTiI2jBIjIDBmmIYHElrgzA=",
"provider-008": "gAAAAAAAD0JAVHspcU_6dp1IpisNd9XULSuwaSsTKKNcw5FtXtUCcLWUVpuFA9qy3DhVIteS83SVyxAOv9aJNc4cgb26z5Q_4-xjCBY2ubH-12pSdTkfsTI=",
"provider-009": "gAAAAAAAD0JAeu1fUbwRb26f07d9C1WPNaIXWuNmMSkulPITmVanLj9IGDQ2fdXf3pkqe171EaUUWiUo9plQC-xC0UOalBQ-HA7kBnPKQuTOqbmBUxOFbws=",
"provider-010": "gAAAAAAAD0JA-BxhhY0FDRAXCg7zAVmm1bHos6KVJt8NrqirUGm_OsWuttNHJw5FfWU_ZCird4uupxQrxpuil1c6FsKbLSfis5wd6cNXGAr0dnoG0bkAzEs=",
"provider-011": "gAAAAAAAD0JArJ3PzUpG7SJev_SpzXIFmpFNnr5uxjvjFmhEEYY2frrtcvheJzqUutllJ4dCecfgUhoFZYZoW6hZzF-VeEiGULtFD6cviCBIJ9rB3Q9n1eI=",
"provider-012": "gAAAAAAAD0JAWDG5yfwVQ5k6ZgFMcZ5BgQT37s9I_uZa24vfGBV7TOPGLDOD3WvejAH2XSm-p70JarQSj6o6f4P-slKBmmlOjBFlor8kN6k5QFKrvl_DrTE=",
"provider-013": "gAAAAAAAD0JAcM0ACOR4TnyaWMSQajkzlh6bpDQkXDiyIjN7JCzQGyPBQ5xnq5o5XVEmdy6s_2KtVNcZ7qn_kxXNvK1tfffgq-08u06MJpzdjDCjt3vBKd8=",
"provider-014": "gAAAAAAAD0JApy7Fg5LFSh_sXlwQ_mIJhropoOnNgfY__LSYrkK65NuNUttVD16i-gn0EZSulZfHLDOjhTLLyFwKOQfjoasuoucJK_R_gegpWu9rgkF2GkE=",
"provider-015": "gAAAAAAAD0JAEql5cEfuPRntNp5p-gIdyfj3Vfiz51KvIl1YISZMYWbQMHXSB1Kud9v5b3WZJGf3EbdCUyGl4-E3xYI85-wSJlCkYaXNJzGaTiZ1OFaqPiE=",
"provider-016": "gAAAAAAAD0JAvE3iFTQdIaFqyM8MCMmR4dxxQMeV-C65_o7m-8BZArFfxBgYTo9zZY_dEj0iVvAM7GORQPEiSr7wJqiOXffLbTsACHRWPMDV-EuKiptxpxc=",
"provider-017": "gAAAAAAAD0JA7RH-NOlL5ARehrlfzea96aDY4rQoE_awhZKZdUUJalJzhvxJJ8B3Z8GpzJlGgM6FSbQfZCIDv1NVh7jikZVfI4Sah1DvS08dx6NrpPyKFp8=",
"provider-018": "gAAAAAAAD0JAQgNdqKNGx1wr-Rz3q9E3nT_Fzhn2Kj-L8XA57p1u6MpHuhUbMTWxojYQKkV5rBW-9ZDPHj4owk1Ac7At0bhoDtI5H-kK-Gj8wmlnfKxUh_I=",
"provider-019": "gAAAAAAAD0JAGLkac-dCevEfv4vHLfGFKav-sjkU1pSGtS1ChrgxKbexQZBsrxW3ThGrpn3zIsjQU-SqpykV3FJF0rRlUDtSy8Du3BMlgNpC2JoyhbUBb1w=",
"provider-020": "gAAAAAAAD0JACZACHyAREZW672Crakxaa0t7R-GFUtqFscUX8nr51oriHlZH3jOu0t6HRAo4KARxT0ZKpnBhlGfM4szpZlhMFwg__WKtMqpxWRrlKYJjh8s=",
"provider-021": "gAAAAAAAD0JAmTxi0REmhen9tXCjNANx2Ik7g2TVVzak-s_hWzwHFlRATxsHg60bpQTNpXFZaC_Z4lSdEO5RMJHqHo3ZT3Gjny4nNqR22fJxsqBJIwC8OFE=",
"provider-022": "gAAAAAAAD0JAPp5h1z5QGy8rwNjNgo7NIGOVC8cnTLG-xL-if6YHLJnQ9DQJ4n6cac3QHfPA-gi1YM6ZalLxbxUy-roueoT59lGQ1Y4OtFDy1jlSXKRn5TY=",
"provider-023": "gAAAAAAAD0JAs0IfMhthrgdTZenjN8gshQGRkiyZG7t2PZfaDv9konfxahHbFFzAW_-U-TKXJQRiXlvSZ_p_3e0upcMJ0Rg8bPPHmo3RwWpe3DD8BwYzY0Q=",
"provider-024": "gAAAAAAAD0JA6DBcyT9ftGfYVoP0aWBdGJENSHu6Jq3z20J004OOcJlHf63zKL4vYR0ZWUfBvpHDz_I2Ol0gLn2woTPv6ul0WB3tzDipo1cH9SUeUS3BUQI=",
"provider-025": "gAAAAAAAD0JA38z2-MzmaHBI--1UAZs8Rxwm7SoDgKPlapvRKrr7zNrI5YVK9v9ZrzJJ3He0oZaKAw3DvYBk9NTJoZuT5Y7eFRX5sHagSlmTLaoysNCFZQg=",
"provider-026": "gAAAAAAAD0JA78csksdCk34WXqhqPIXRd7QpxkoAlrudALMtTuLXOEmIoEIhv2d28fQFok2aq-CAS6CcVCVJhx2hvgpV6PG09tJSWDZTBjqqSp-ANCQsEy8=",
"provider-027": "gAAAAAAAD0JAPLfJkfciuZBZxQFfKsFTVRAGEVRPXGQk_v5WpwvIpmJI0ng_xfeX0T8De9RD02yUuEslI_Gfh_nm-47yVwNIgw-vN3_07eMKKENRNQlKwDk=",
"provider-028": "gAAAAAAAD0JAOQYHZstCBM1A1RPKfHZF67nerogpSQo_sw6pobxsr6oOQ4mNXoDksfEvdLmBzHTQT30UHFbbVHW3r3uMoinjv23-02g9nVM8oOu3mYoZRYs=",
"provider-029": "gAAAAAAAD0JAVGmZPBVRSgBNVGHWbVgUfjP6X2s9CZGenb4c1NHMEC8Oj6Ux8Pwyo0jqyxUMhkqBS8HG7GcWliYzoooxP8b1vN4bXB8GkzfCm2Co-Jfo52A=",
"provider-030": "gAAAAAAAD0JAI6-pQy8G-U3m0S62sMIWPbjndADsysZinZcMlvaH42i-iWCufh-b7MLLh8E1Ki6BQOfbq6K7JyFLL0moarHvuiUNX71rMwFDw6yutshda6Q=",
"provider-031": "gAAAAAAAD0JARThZQX-8Aeoc9M2eNUSwZfQhXmgGmrY6f_nSLbKnM2v8Jivmz-0yLqBTgEQK5firYXim_Uysj09gSC3Bw0_TO7-IkDAQrqNFmVEhwN__Jyk=",
"provider-032": "gAAAAAAAD0JAkEIJgqfvq3ts1jKBYSFDahPn8c7uieUzckrvezbhrfbHEn57BpdH0A6xjFCzBiLlIbU5h-THiVQalKBP8fH80G3GrAcvlx7_9n-KbmE_g1E=",
"provider-033": "gAAAAAAAD0JADU8fN01753ribq0QDDJK9zNgYSgboF-xwpIKJFvwL6GUPr07JCtkz1riGtyU7IYWQHNIx9FyC8G9Jp6eBTycB9Dhm2S4t6X62z8HcG2tG5A=",
"provider-034": "gAAAAAAAD0JA8PhvLE-sVw_bkdcfdr2AX_wVJHWVZDMgln1cOptYmYIdF2ToreQIyNz0ElcSIVZCfyMsKCehz6cuCcetZoLVtBrL5V1ZpWGjqOviXsG5N1Y=",
"provider-035": "gAAAAAAAD0JA29XAVBWD5VjF6Yzmm1PbTESQqkCXB12yyMuT4MnJVweqlMJRX6IJtSfxjwzavko_xLXxHdIv9jYb3sHNXGUpNI5vA-VVDxL928PX695NyXc=",
"provider-036": "gAAAAAAAD0JAUfb73PYyObLEaulCDuLzUNOBDFb1P-GlowbmYxSIlX1Y2A_4FluLopM5XRQl-p94SkwnGZuu_wdieoBpXoz4RZMuKgLlYMfI_B6QAh4zGtI=",
"provider-037": "gAAAAAAAD0JATbfi9uRGgV64yB1u1XLytvUWRGXu8c5bIjr_N6jxUfw8CL4QTtAj092R2FT2lZ2p3x122MU4AyQJFZO-3ARnV1co7w8_gcIoJ3WP19knGIk=",
"provider-038": "gAAAAAAAD0JAKQ3N0O10RN4RId6ZtU2N5fB95sJ4Rt29PQx6VY452GgvttuZFnLJjG44xL4Et_ml27t4Ylu507I86E14yjPI18AnhtagNc0Or-mX3C5J6BY=",
"provider-039": "gAAAAAAAD0JAUmO46r3Stv4c6PLSZxp4vsh1fDvD34e-ukpuyoc6LSO-ijgP20L3Fl2medshUT9KBhgTIXmacUlmZwHD45TPyUsiuh09p6ZrZNuKtlwtcFM=",
"provider-040": "gAAAAAAAD0JAqkGVoQO8cLp-saeL-C5oB_VJh-Oln-HgJgrUlxBCUrsbdYO6oXwUjFWIY-mf9sbide-FlJ9ypbsiuOXWTkkMViIwBntF5P6IduR9fpvhFTQ=",
"provider-041": "gAAAAAAAD0JA0CnlOHJXTwBoeeg0xIxHSi-sn0b4pExJ-z_omfIuvgQYEI2t8FuVK5BVmiqqhpF-X0nOyGJScyNYD30G6s1kXJGkk2wczfAJ56Gr9evt4y0=",
"provider-042": "gAAAAAAAD0JABqbpp-3wu10rJYC_Bcw0PjTS_NSQZ2T2cRbCrP8qX4kOtYF5G8ihmCT3ir427NouPKKiJM4xJLDmVBGohyLwLreGYZJsGrJPTSbvrMevfv8=",
"provider-043": "gAAAAAAAD0JAnZiu7eyx9mQys1lBbkVAIUP2_CyClX39hyEXUuheuvQni0W0vrGqtuwl3g_eQo1UiiO-BoI0TGwJ8T5Kz-vFhbfDnkwEXLTNkVhxXqAgsS0=",
"provider-044": "gAAAAAAAD0JALfymh09LXoeVEQue5fExnPn_dSc5brevZ1IGCGvdboQkRFHrI1bTxBzVjT4mKxZq1C5pmBEdaapiwzyV11vYUvugl-Rw8ofbgi13Bm_OFS8=",
"provider-045": "gAAAAAAAD0JAjmxdzZpREmCgS2125qbKBxTJOlgb6dRvVQqdyyQK7TvlNXD4M9CpJRcbdGq7QOJHvl0lse1lUUSN9wi45O2RzNvEW4qSlbKx65h291kXXe0=",
"provider-046": "gAAAAAAAD0JA6H3YMHtFKu3oKtBOprisdbrA7yFb-bgCUkoQLwtw6NlSjXnIzFczOaQlN7j8Thgt3T_1YnX6zI-3WiJSKwbEWic1XEo3SCRn4TJHI_7X4EE=",
"provider-047": "gAAAAAAAD0JAw6-N0FfdvMGI3-zJ5fYHlr_Ki0CdiYB30XUIAkOzJKD7uPotZRYmjfWGvk-slhyuRR6I2H45I_uJ3L7pnXKwULushf5ssYcYBTCcDcUY13s=",
"provider-048": "gAAAAAAAD0JAZI__BvdD6dY6BOg2YaQdnBzVnGVTPWSFl_67ImM6DKzVnOho-eGaeWpONjJhXFEjJnc1GzhdjgznHJldUG6bz7wzq25sOw_-ZMgJ4xzTYTw=",
"provider-049": "gAAAAAAAD0JAuxRPYRIYy6cCClZj9lsRSadWjicUxThqfll0rL0OaAMYyxw3Y94c7AmJQle3XEY7rtTXrfpXa5OvOD7LSQyKmDayG_Ao7LLFlJxjHXVQnu8=",
"provider-050": "gAAAAAAAD0JAIb2OzqcQxC_Lzb4d7YT0hbvIYzb0GZTx7_6pFosrUk6tMl3FrqP11tNhFpjDoie1WV-eL-XK9hXkS74v3icXNwXa8V7hCMIxVOP65sSWtCw=",
"provider-051": "gAAAAAAAD0JA3kE4__P4FjzyW7fYbl30VkuGWesb2qr-Yx3RE5eZNvyFKo_t1p_smwaq4Wvz12AgCHmQ1gcBZzylVRgK3eG5fAuUYqfMjFwawfTyL3WURE8=",
"provider-052": "gAAAAAAAD0JAIZ5MtE8IOu0hJmzygrrcj-_Ll2AHcBw30rAx9OoVJWh_aSPL2QYjzcGO8xeS_1C_tknFw1WhVF5Uan46IXARdWOY7bnSSVnNKZMX_x_6gDE=",
"provider-053": "gAAAAAAAD0JAwf1PNFe3MaDq7GenXs_Td2zi_cn6FBWcl-tXt_oD2mew2eJ1i30A-IQQMVGuq4nA_wcBuwO8vCJ522XIpLYvhjQE1a1wZkEMtGniRgkJBM8=",
"provider-054": "gAAAAAAAD0JA0f8SsCpmEdpznx_GZo0nuyVEDZaK1GqGMKPqa8T-g4HViS34KovNriBZc-C5Vli7BVplJ547qSGQiATAYiDS69FvE9Mo_fK_RpZsaSVjq3A=",
"provider-055": "gAAAAAAAD0JAj9lMUNoW_vcgRaRHtOCfaaejTQCRMJw_jkObw0r26C7nVgt-Pu7HZm4_9kHuYl4fkm8MbAHIeXfbQp0R9HANDycyx-PSibOk2tGsFFUe9BU=",
"provider-056": "gAAAAAAAD0JAV1dj3mzw5D9bCUv1PZ5Qx2kR0tnhGm37xWxEqS9N-nVj8hu36dkcvJa43Nls7k4ZmQR1mlB00cKECr8M118QG740QMLBHc8HT0EtMuaHUHw=",
"provider-057": "gAAAAAAAD0JA0JIBXd3l6bDibgxCjyHjRdnaoPA2IIaZw1YvtFFc7REAmwloQ2ytnTVN_5lDzWMmtt5tI8dhlD4FqiD9i_ehn-Tom8sMbOU_MkprTtGdQZY=",
"provider-058": "gAAAAAAAD0JAYSoZSA2Gys7bL23LvdCLcvSIeG0kMvY2Az6equFSKGzSHCs2gUF12s7Z-AlFLmRvl5Kw2ZFGqFg0V0OEnKFV_-SCnYzToJ8QPIhHrtRO8t0=",
"provider-059": "gAAAAAAAD0JAUB2Rf_yW5j12i_K693ebcPB24HgRUbhFrMA3ylL-s_MTMqSHATUEDOcA6hLYrbnHkhRqibKUSaSVPt7n0STk90m7Tt5m6KHdmYH1pM09NFo=",
"provider-060": "gAAAAAAAD0JA9DBlKLX-bXYcJhNvHPs1m4_ndWfcuZWU0kMvqhY8hBD0nvt0-UZiNANE33LVrGpSoK5IUbUq6uIAqgiU_Q0lyYWD6Zrv1NL0nozegE2MFYI=",
"provider-061": "gAAAAAAAD0JAwW5G-mJSUkSV4CIBLPtJKQvNKd2Ssjy-srdvExhpPBGAwkIv9yCu_dgM7fBLHRoNuT2cA1FYUu0qKu_lu1Yskxc_g9HG8oZ6WgrUGdmgyTo=",
"provider-062": "gAAAAAAAD0JAbgX9K0OcfZpbb6cK7dJ7Q1tRQLaWCqJNYchBLDgD7N_tC0tOdYHS6Z623oomyUGgooYGKvF6jPhV1LAnD3RtXIpT6OfgDC5hhijKXBGDK5U=",
"provider-063": "gAAAAAAAD0JAKrZAEjUOgmSnTWdpa6wQWOaawO5cK6vwZhJlAaQIOF6SlCURV2-cfNwg5l2vm7ilhP12Qai7S2ljVP3fjsxqN0RcJY4ufUszuwCS-wDcfiM=",
"provider-064": "gAAAAAAAD0JANTxIEMEAxmFDOR4Trm7BZsCRtJLohH0sIrNhhnksy_RwR8lAWddm1Mve-9hy4vvxAuLR1pKRntyK02DD6xSD91Zv-nFN2mhwxww_9GMAUq4=",
"provider-065": "gAAAAAAAD0JAlXfTEsE0QOarTDgc9DG2WNc1kmjx7Jx5EfC1csicneTjsyl6sSb1Et5u5d-OmxcYupRWa3fplAVQyyHSkOtVims1v4rdshKjEsmRHj0j_H4=",
"provider-066": "gAAAAAAAD0JA0MZiSxiJA248fgj7jpUFHde2rQfPIckAKCF515JuBZaVyHEVDdEiog_gyth_QJHERpvJFU58weSlUA-UQRco-mZKaR4WJl5Pt2jVhYQ5SWU=",
"provider-067": "gAAAAAAAD0JAe7XCcR5qvu8WVXZO9hORAQGbhVJEwd3PRSzMq0HZFaIyIfRaIxT7t08GOVmX6P28c3ChTdzq4NPmnbCPLa0xWJ553A_jwszzKw7KnewMapY=",
"provider-068": "gAAAAAAAD0JAUc1xmeaMgsjTVn0o6z0ip-rtMssELSXWGZq45_B_YSlu2jYCocM7R2kKiZ26DMqFh1IEAOviiaTcbI74O9ofpoSD7mg48BAP4rZG1xOHXak=",
"provider-069": "gAAAAAAAD0JABmFuB-1Z_DiB6GFP7eqWCkJpcQmewraRzIdlpoxiiY-TN7m1m6jWFjL2yg9yAAagE2_RoNGbgcW8suvFPlgxbqxlYcF40PcEKxCQFwS_nxE=",
"provider-070": "gAAAAAAAD0JA6Sq2H9uxMrjMJle4M82jhKIxAjskPb_I1eas6Sn60wYQsP9JqiCAFm99a6vpEgWH9gJI9HdLFrmVv7CKy7FIlSl8zO61cfbvxLm78QqG9vM=",
"provider-071": "gAAAAAAAD0JAkpEwTNJN7m82wRRXatd_GjY0Ii2Q4fDSpto5pCgUBffW1hgQr8JwKY4jkO37cLXTVHwh95a76fqbSn7rfHjjNCuGnT99Rdm3md-iqn7D7QE=",
"provider-072": "gAAAAAAAD0JABDTb_d5tRZ2MCVDGKFLFlYdNvgV5WgxUQfubYRoXj_2spZ1IJY0nA9jnOK5_kMqtt8AAk8MowT_O1Gl7wraQozij002mHay_0KpByQZyH1Y=",
"provider-073": "gAAAAAAAD0JA35Hype3h5YrRm1DP04j9d3NcBtSv5DyE8d9-plL17gV6xXa9e3vrIpMNhPLYckCHULbeq_mTGXqpHCITGkyteT2fn8WVLOaFEZ7KxJrZ1ao=",
"provider-074": "gAAAAAAAD0JA53v2Tla3kX3nIaUsaWqdVmKe41U5UCUolouSSv2LqyZZb9l_P5J53ZEOlQC49ZjglwL4fvjmvkg6RyZfAVtITmX0RXIkykzvEvTyIlvVV-c=",
"provider-075": "gAAAAAAAD0JA4bc7magJ5p4ftSOFjt74xAQJlw0_Kr7WyLr25Rj1i8LeBbZTKJ3erKZXZwbLwpN92FObMHHOUbxqq0qKp4Kmvw4wF5C3Bm_LCVD_TKX9DxQ=",
"provider-076": "gAAAAAAAD0JAkVGEJLIdj7cJxMetMzxYt4NrmCLRys28zCeeorUOfcuiqNNImRShzYc9lLtf7w7YoZOhueQ5it2xcCev1DV4RDFJM2tKPjnXroIGYAFbhUw=",
"provider-077": "gAAAAAAAD0JAsQsksP6Z1_YfPix6LBNiILF-y1-fWlku-3wl7hq0DwEDiqr8D-yu3CLfkcVU7E5j9UHbRE2RqT9Ac7Pm2f-Q9Uiliwx1fLK3uFmJuiyrM3c=",
"provider-078": "gAAAAAAAD0JAhhjIEFvZS6kF3IUaQqn1x0aLrOgRcPdcM27tsIXyB7vwZSgqVk7VZIYIr60mupRQD8q1vIo66wFRyPcOO_TdFzUjZXGPX5FvQNpn6s8Vbwo=",
"provider-079": "gAAAAAAAD0JAe94_XDzvvJyJUTqft6Gf7mrpJzmbh9_9X6vdihLktm_0IbuBgm-8qtaMjAlkAp8Wsp3OC-gKSHcQu9ta60N-NUSFCOKIIjvgZbNpB3JNNdk=",
"provider-080": "gAAAAAAAD0JAWYtwtu1cbp3XlEDWjZlBp8LQQ9uO3wFLsdxh60ndemLs3Qmw-2-U2IYSweFqEsAtIYKeNwIofxDq2yAYPCGeg23KtmfWN3Jc_3tpL3GPO1I=",
"provider-081": "gAAAAAAAD0JA1q0GlZjz1qSiM9-S4SNQj_s0ean9rQiKWFi0W2Qu7rKATAmGaZV3J5LQGgfXL3s7K1atVirbQ9mbVRcp2-Nh-yfnTt2nEIE8dsrqaU7FUrM=",
"provider-082": "gAAAAAAAD0JAS4i0A_rFY6SkN1QLpo9wtSNrYWl9u5_9rx7Pv4xHJSPcSs8313mcpNXOponmpVSPKJih3xuOlinBAGPT-WkumY_JLNOMGmu5Hcc6k-UOuVc=",
"provider-083": "gAAAAAAAD0JArrVSvJmJ-9liaD5_Npm_jEJqIXUjTGeWEIkQSjDq1V8TpVSr4b3Guz2N0KGDVZCUpZImgXvsCSY73pCOuMShj5bCi8YBPsf1JgsamYFU_Z4=",
"provider-084": "gAAAAAAAD0JArCrbHbZ_7W51T9WFIsYIYLsNF3ihbs-5DOEKbBLDgbw9aAcfVa1aCwnNESwg8OBABvpKr-5F4Zph_FcGG1LpHd2Ye2pDxx5Ul4Z4Ql6em2w=",
"provider-085": "gAAAAAAAD0JAjlher3s8linCmF_bpTSoQOb-LT3zZAiwd-3T9xMlItGCe6V_R2nt_j8pS2DHIEbLuLriLGwCuVCFDwPPqxODzsRXA9R8goVsH--XMWIDx54=",
"provider-086": "gAAAAAAAD0JAOvWWo66DTyKorw6xougky5D3lk5TiyxXWV-s6Qtsbk1YjCC10Xhk67HExMdcoW9UoeDZLIoqhUBzmNsShiKt9YufI4YYSfPm9aL0gXx2Iug=",
"provider-087": "gAAAAAAAD0JAYyndje5fZAtH9g3nDmE8kp0ZBNTdF6TLgMsErqzInyZRoL3kEVovX26gEgpdBvDJ1-5KvO3dMoXl4ah6scY6O6USnNnKSDDa0CgE9rFG5JU=",
"provider-088": "gAAAAAAAD0JAs40wY8C--WZ9TPF2RzD2Z0riPKgAecv3tBuZ0EEhDrkfST5bz3LJk1_jFUDEsR1BCQYQ_jH_j_AFgjACZnXMAdQcXPKKMei6dpCta_X0dtE=",
"provider-089": "gAAAAAAAD0JA3YClZN2rmHNhTljyP7NOFxdP__GKswG8yDWM-sXwzkcZKxF6FqeZTlISPMtYz5oh_-cpztajYq9ki3kPh4nnGyQZ_MqLq1WRPAt80-YU8Lw=",
"provider-090": "gAAAAAAAD0JAZKyzODbB7olM9rwwSDX6H9a56GtE7hwZuSsKmiS02WUop6w2Lymg-Xm8thwmMnLF-1j5KpGCBo2nH4Xd5x2DuBY35udDRoNobN5BC6-f30Y=",
"provider-091": "gAAAAAAAD0JAD6XKTSgi3huiv0PWsoNF7yH30KDzs0f2cHwW90NRT3nxTjsU6XDVzBrh57vd9kV8ofV-C4Qemf5tpbLirhD1aW9jhdvVfckhTrUQqEitvLo=",
"provider-092": "gAAAAAAAD0JAr8ekPqcoNB4KEjo_gfaoOCcvvpD3ykweN5VxFj_EBckVswLb5KicTwlN7a_a8pJQBbJrsps-mLDDBkxau_EwGSyWxYmVVzaPRbqnCejWaXw=",
"provider-093": "gAAAAAAAD0JAp0KUmZFCNpxy45Y3yKJP2-6ovo8keTVJjKVblQbU_1GSc2YgZcaMMQoHxSz9VMXz-gSuAOhW6fexPyN5QH7JqZIzfrQsIA7XebXo29A3ocI=",
"provider-094": "gAAAAAAAD0JAhBwwkb_yZHrQ2UsRekxEESVaKk1xh46yHJRCO5VMew0SaUy3Sk1HandiQkMXmyHc95leNkRBNcu3vRv7_AIo5hCETlm_wub5aZs3QNuuncw=",
"provider-095": "gAAAAAAAD0JAZASXeJl8WY0k4yT0A4TJQkO_YrGSN-LQEo-_Q6D33v-CXhxPFIUq_AEKlEbnBWgriqRQ5bFC8UtxCvYvJNzV9GIVassojICamFgAPUzJIYg=",
"provider-096": "gAAAAAAAD0JArGgydkoNqqplDShZoTP9VyPRvWDSgLy6lXuAwZPzt-i6E_Xalu5Ww73V4ca6gA6TptseKv35k2p2RZts_LyiWKY4ih_PqjGlrqVbydtazf0=",
"provider-097": "gAAAAAAAD0JASCJxfWQ4irHGf5QUq27tti1CLKUKvxN-wZ5KL5SobwK9s2_tlWbpnnFSwxTWyAr_3PI2PeLfB1WortkvnFtjL1u9V5eaZCXn3khRy14_9oI=",
"provider-098": "gAAAAAAAD0JAZ-K7BGACvTdqfMSRbFcOxaDw-o-TDEN3sIOLrnhePkhFitxThopo2WyRoFUGtOJdWW_U7XMvM_K9-1K-zC8bYphlZOh92rHO0HRyaJ40qMY=",
"provider-099": "gAAAAAAAD0JA6tr96Hbphykg8LKZI4jbIatMSbpVtFZYQtcl4arnshQm2AEu2Z0hblzM8OSvHQWHgl25XItM8XdabZPXRfCD8wVdGI52t-yh4XVCzMQkV7U="
}
}