mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 05:12:03 +08:00
CA 适配: - 移除 CA 模块对 macOS/Windows 的硬性限制,Linux 走完整初始化流程 - 按发行版自动选择安装命令:Debian 系 update-ca-certificates, Fedora/RHEL/Arch 系 update-ca-trust extract;已安装检测比对信任锚文件 - 删除不可达的 CaState::Unsupported 分支及前端"请使用 macOS 或 Windows"提示 静默启动(参考 cc-switch 实现逻辑): - 新增 DesktopSettings 持久化(service_settings 表)及 GET/PUT /api/settings/desktop - 主窗口统一以 visible(false) 创建,启动时读取配置决定是否显示 - 应用设置页:开启"开机启动"后显示"静默启动"子开关 安全修复: - ProviderEndpoint.api_key 增加 serde(skip_serializing), 避免明文 API Key 随 JSON 响应泄漏(provider_console 测试恢复通过) 其他: - Linux 补齐 open_terminal_with_command:按序探测 x-terminal-emulator / gnome-terminal / konsole 等并在新终端窗口执行命令 - "打开终端安装 CA"失败信息不再被前端静默吞掉 - 顺手修复上游遗留问题:desktop.rs 未使用导入、output.rs 测试模块位置、providers.rs 格式
265 lines
8.8 KiB
Rust
265 lines
8.8 KiB
Rust
use std::{fs, path::PathBuf};
|
|
|
|
#[cfg(target_os = "macos")]
|
|
use std::process::Command;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod windows;
|
|
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
use rcgen::{
|
|
BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, Issuer, KeyPair,
|
|
KeyUsagePurpose, RsaKeySize, PKCS_RSA_SHA256,
|
|
};
|
|
#[cfg(target_os = "macos")]
|
|
use sha1::{Digest, Sha1};
|
|
use time::{Duration, OffsetDateTime};
|
|
use x509_parser::prelude::FromDer;
|
|
|
|
use crate::{config::managed_data_dir, Error, Result};
|
|
|
|
use super::CaState;
|
|
|
|
#[derive(Clone)]
|
|
pub struct CaManager {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
pub struct LoadedCa {
|
|
pub issuer: Issuer<'static, KeyPair>,
|
|
}
|
|
|
|
impl CaManager {
|
|
pub fn managed() -> Result<Self> {
|
|
Ok(Self {
|
|
dir: managed_data_dir()?.join("ca"),
|
|
})
|
|
}
|
|
|
|
fn cert_path(&self) -> PathBuf {
|
|
self.dir.join("ca.crt")
|
|
}
|
|
fn key_path(&self) -> PathBuf {
|
|
self.dir.join("ca.key")
|
|
}
|
|
|
|
pub fn state(&self) -> Result<CaState> {
|
|
let cert = fs::read_to_string(self.cert_path());
|
|
let key = fs::read_to_string(self.key_path());
|
|
match (cert, key) {
|
|
(Err(cert_error), Err(key_error))
|
|
if cert_error.kind() == std::io::ErrorKind::NotFound
|
|
&& key_error.kind() == std::io::ErrorKind::NotFound =>
|
|
{
|
|
Ok(CaState::Missing)
|
|
}
|
|
(Ok(cert), Ok(key)) => {
|
|
if parse_issuer(&cert, &key).is_err() {
|
|
return Ok(CaState::Invalid);
|
|
}
|
|
Ok(if is_installed(&cert)? {
|
|
CaState::Ready
|
|
} else {
|
|
CaState::Untrusted
|
|
})
|
|
}
|
|
_ => Ok(CaState::Invalid),
|
|
}
|
|
}
|
|
|
|
pub fn load(&self) -> Result<LoadedCa> {
|
|
let cert = fs::read_to_string(self.cert_path())?;
|
|
let key = fs::read_to_string(self.key_path())?;
|
|
Ok(LoadedCa {
|
|
issuer: parse_issuer(&cert, &key)?,
|
|
})
|
|
}
|
|
|
|
pub fn install_command(&self) -> Option<String> {
|
|
let path = self.cert_path().to_string_lossy().replace('\'', "'\\''");
|
|
match std::env::consts::OS {
|
|
"macos" => dirs::home_dir().map(|_| {
|
|
format!(
|
|
"sudo security add-trusted-cert -d -r trustRoot -p ssl -k /Library/Keychains/System.keychain '{}'",
|
|
path
|
|
)
|
|
}),
|
|
"windows" => Some(format!(
|
|
"certutil -addstore -f Root \"{}\"",
|
|
self.cert_path().display()
|
|
)),
|
|
"linux" => {
|
|
let anchor = linux_anchor_file();
|
|
Some(format!(
|
|
"sudo cp '{}' '{}' && sudo {}",
|
|
path,
|
|
anchor.display(),
|
|
linux_refresh_command()
|
|
))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn initialize_local(&self) -> Result<()> {
|
|
match self.state()? {
|
|
CaState::Invalid => {
|
|
return Err(Error::Config("CA files are incomplete or invalid".into()))
|
|
}
|
|
CaState::Ready => return Ok(()),
|
|
CaState::Missing => self.generate()?,
|
|
CaState::Untrusted => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn generate(&self) -> Result<()> {
|
|
fs::create_dir_all(&self.dir)?;
|
|
#[cfg(unix)]
|
|
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?;
|
|
|
|
let key = KeyPair::generate_rsa_for(&PKCS_RSA_SHA256, RsaKeySize::_3072)
|
|
.map_err(|error| Error::Config(format!("generate CA key: {error}")))?;
|
|
let mut params = CertificateParams::new(Vec::<String>::new())
|
|
.map_err(|error| Error::Config(format!("create CA parameters: {error}")))?;
|
|
let mut name = DistinguishedName::new();
|
|
name.push(DnType::CommonName, "Cursor BYOK Local CA");
|
|
name.push(DnType::OrganizationName, "Cursor BYOK");
|
|
params.distinguished_name = name;
|
|
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
|
|
params.key_usages = vec![
|
|
KeyUsagePurpose::DigitalSignature,
|
|
KeyUsagePurpose::KeyCertSign,
|
|
KeyUsagePurpose::CrlSign,
|
|
];
|
|
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5);
|
|
params.not_after = OffsetDateTime::now_utc() + Duration::days(3652);
|
|
let cert = params
|
|
.self_signed(&key)
|
|
.map_err(|error| Error::Config(format!("generate CA certificate: {error}")))?;
|
|
write_atomic(&self.key_path(), key.serialize_pem().as_bytes(), 0o600)?;
|
|
write_atomic(&self.cert_path(), cert.pem().as_bytes(), 0o644)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn parse_issuer(cert: &str, key: &str) -> Result<Issuer<'static, KeyPair>> {
|
|
let key =
|
|
KeyPair::from_pem(key).map_err(|error| Error::Config(format!("parse CA key: {error}")))?;
|
|
let pem = pem::parse(cert).map_err(|error| Error::Config(format!("parse CA PEM: {error}")))?;
|
|
let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(pem.contents())
|
|
.map_err(|error| Error::Config(format!("parse CA X.509 certificate: {error}")))?;
|
|
if parsed.public_key().subject_public_key.data.as_ref() != key.public_key_raw() {
|
|
return Err(Error::Config(
|
|
"CA certificate and private key do not match".into(),
|
|
));
|
|
}
|
|
if !parsed.validity().is_valid() {
|
|
return Err(Error::Config(
|
|
"CA certificate is outside its validity period".into(),
|
|
));
|
|
}
|
|
if !parsed
|
|
.basic_constraints()
|
|
.map_err(|error| Error::Config(format!("read CA constraints: {error}")))?
|
|
.is_some_and(|constraints| constraints.value.ca)
|
|
{
|
|
return Err(Error::Config("certificate is not a CA".into()));
|
|
}
|
|
Issuer::from_ca_cert_pem(cert, key)
|
|
.map_err(|error| Error::Config(format!("parse CA certificate: {error}")))
|
|
}
|
|
|
|
fn write_atomic(path: &std::path::Path, data: &[u8], _mode: u32) -> Result<()> {
|
|
let temp = path.with_extension("tmp");
|
|
fs::write(&temp, data)?;
|
|
#[cfg(unix)]
|
|
fs::set_permissions(&temp, fs::Permissions::from_mode(_mode))?;
|
|
fs::rename(&temp, path)?;
|
|
#[cfg(unix)]
|
|
fs::set_permissions(path, fs::Permissions::from_mode(_mode))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn fingerprint(cert: &str) -> Result<String> {
|
|
let pem = pem::parse(cert).map_err(|error| Error::Config(format!("parse CA PEM: {error}")))?;
|
|
Ok(hex::encode_upper(Sha1::digest(pem.contents())))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn is_installed(cert: &str) -> Result<bool> {
|
|
let fingerprint = fingerprint(cert)?;
|
|
for keychain in ["login.keychain-db", "/Library/Keychains/System.keychain"] {
|
|
let output = Command::new("security")
|
|
.args(["find-certificate", "-a", "-Z", keychain])
|
|
.output()?;
|
|
if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(&fingerprint)
|
|
{
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn is_installed(cert: &str) -> Result<bool> {
|
|
windows::is_installed(cert)
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
fn is_installed(cert: &str) -> Result<bool> {
|
|
match fs::read_to_string(linux_anchor_file()) {
|
|
Ok(installed) => Ok(installed.trim() == cert.trim()),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
Err(error) => Err(error.into()),
|
|
}
|
|
}
|
|
|
|
const LINUX_ANCHOR_NAME: &str = "cursor-byok-local-ca.crt";
|
|
|
|
fn linux_anchor_file() -> PathBuf {
|
|
if PathBuf::from("/etc/pki/ca-trust/source/anchors").is_dir() {
|
|
PathBuf::from("/etc/pki/ca-trust/source/anchors").join(LINUX_ANCHOR_NAME)
|
|
} else if PathBuf::from("/etc/ca-certificates/trust-source/anchors").is_dir() {
|
|
PathBuf::from("/etc/ca-certificates/trust-source/anchors").join(LINUX_ANCHOR_NAME)
|
|
} else {
|
|
PathBuf::from("/usr/local/share/ca-certificates").join(LINUX_ANCHOR_NAME)
|
|
}
|
|
}
|
|
|
|
fn linux_refresh_command() -> &'static str {
|
|
match linux_anchor_file().parent().and_then(|dir| dir.to_str()) {
|
|
Some("/usr/local/share/ca-certificates") => "update-ca-certificates",
|
|
_ => "update-ca-trust extract",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn generated_ca_is_loadable_and_uses_private_permissions() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let manager = CaManager {
|
|
dir: directory.path().join("ca"),
|
|
};
|
|
manager.generate().unwrap();
|
|
manager.load().unwrap();
|
|
assert!(manager.cert_path().is_file());
|
|
assert!(manager.key_path().is_file());
|
|
#[cfg(unix)]
|
|
assert_eq!(
|
|
fs::metadata(manager.key_path())
|
|
.unwrap()
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o600
|
|
);
|
|
}
|
|
}
|