fix(desktop): accept self-signed certs from configured external goosed host (#8400)
Signed-off-by: jh-block <jhugo@block.xyz> Co-authored-by: Mathis Rocher <mathis.rocher@corp.ovh.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: jh-block <jhugo@block.xyz>
This commit is contained in:
Generated
+1
@@ -4604,6 +4604,7 @@ dependencies = [
|
||||
"hex",
|
||||
"http 1.4.0",
|
||||
"openssl",
|
||||
"pem",
|
||||
"rand 0.8.5",
|
||||
"rcgen",
|
||||
"reqwest 0.13.2",
|
||||
|
||||
@@ -76,6 +76,7 @@ rcgen = "0.13"
|
||||
axum-server = { version = "0.8.0" }
|
||||
aws-lc-rs = { version = "1.16.0", optional = true }
|
||||
openssl = { version = "0.10", optional = true }
|
||||
pem = "3.0.6"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = { version = "0.55.0" }
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::middleware;
|
||||
use axum_server::Handle;
|
||||
use goose_server::auth::check_token;
|
||||
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
|
||||
use goose_server::tls::self_signed_config;
|
||||
use goose_server::tls::setup_tls;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::info;
|
||||
|
||||
@@ -78,7 +78,11 @@ pub async fn run() -> Result<()> {
|
||||
if settings.tls {
|
||||
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
|
||||
{
|
||||
let tls_setup = self_signed_config().await?;
|
||||
let tls_setup = setup_tls(
|
||||
settings.tls_cert_path.as_deref(),
|
||||
settings.tls_key_path.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let handle = Handle::new();
|
||||
let shutdown_handle = handle.clone();
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct Settings {
|
||||
pub port: u16,
|
||||
#[serde(default = "default_tls")]
|
||||
pub tls: bool,
|
||||
pub tls_cert_path: Option<String>,
|
||||
pub tls_key_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -91,6 +93,8 @@ mod tests {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3000,
|
||||
tls: true,
|
||||
tls_cert_path: None,
|
||||
tls_key_path: None,
|
||||
};
|
||||
let addr = server_settings.socket_addr();
|
||||
assert_eq!(addr.to_string(), "127.0.0.1:3000");
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! / SChannel respectively, but `axum-server` does not offer those backends so
|
||||
//! the server listener always uses OpenSSL when this feature is active.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Result};
|
||||
use rcgen::{CertificateParams, DnType, KeyPair, SanType};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
pub type TlsConfig = axum_server::tls_rustls::RustlsConfig;
|
||||
@@ -65,6 +66,45 @@ fn sha256_fingerprint(der: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load TLS configuration from user-provided PEM certificate and key files.
|
||||
///
|
||||
/// The SHA-256 fingerprint of the leaf certificate is computed and printed to
|
||||
/// stdout so the parent process (e.g. Electron) can pin it, just like the
|
||||
/// self-signed path.
|
||||
pub async fn from_pem_files(cert_path: &Path, key_path: &Path) -> Result<TlsSetup> {
|
||||
let cert_pem = std::fs::read(cert_path)?;
|
||||
let key_pem = std::fs::read(key_path)?;
|
||||
|
||||
// Parse the first PEM block to extract the DER-encoded certificate for fingerprinting.
|
||||
let der = pem::parse(&cert_pem)?.into_contents();
|
||||
let fingerprint = sha256_fingerprint(&der);
|
||||
println!("GOOSED_CERT_FINGERPRINT={fingerprint}");
|
||||
|
||||
#[cfg(feature = "rustls-tls")]
|
||||
let config = {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem.clone()).await?
|
||||
};
|
||||
|
||||
#[cfg(feature = "native-tls")]
|
||||
let config = axum_server::tls_openssl::OpenSSLConfig::from_pem(&cert_pem, &key_pem)?;
|
||||
|
||||
Ok(TlsSetup {
|
||||
config,
|
||||
fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set up TLS, using user-provided PEM files if both paths are given,
|
||||
/// otherwise generating a self-signed certificate.
|
||||
pub async fn setup_tls(cert_path: Option<&str>, key_path: Option<&str>) -> Result<TlsSetup> {
|
||||
match (cert_path, key_path) {
|
||||
(Some(cert), Some(key)) => from_pem_files(Path::new(cert), Path::new(key)).await,
|
||||
(None, None) => self_signed_config().await,
|
||||
_ => bail!("Both GOOSE_TLS_CERT_PATH and GOOSE_TLS_KEY_PATH must be set, or neither"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and
|
||||
/// return a [`TlsSetup`] containing the server config and the SHA-256
|
||||
/// fingerprint of the generated certificate (colon-separated hex).
|
||||
|
||||
@@ -41,6 +41,18 @@ const i18n = defineMessages({
|
||||
id: 'externalBackendSection.secretKeyHelp',
|
||||
defaultMessage: 'The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)',
|
||||
},
|
||||
certFingerprint: {
|
||||
id: 'externalBackendSection.certFingerprint',
|
||||
defaultMessage: 'Certificate Fingerprint (optional)',
|
||||
},
|
||||
certFingerprintPlaceholder: {
|
||||
id: 'externalBackendSection.certFingerprintPlaceholder',
|
||||
defaultMessage: 'AA:BB:CC:... or sha256/base64',
|
||||
},
|
||||
certFingerprintHelp: {
|
||||
id: 'externalBackendSection.certFingerprintHelp',
|
||||
defaultMessage: 'Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU).',
|
||||
},
|
||||
restartNote: {
|
||||
id: 'externalBackendSection.restartNote',
|
||||
defaultMessage:
|
||||
@@ -189,6 +201,25 @@ export default function ExternalBackendSection() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="external-cert-fingerprint" className="text-text-primary text-xs">
|
||||
{intl.formatMessage(i18n.certFingerprint)}
|
||||
</label>
|
||||
<Input
|
||||
id="external-cert-fingerprint"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(i18n.certFingerprintPlaceholder)}
|
||||
value={config.certFingerprint || ''}
|
||||
onChange={(e) => updateField('certFingerprint', e.target.value)}
|
||||
onBlur={() => saveConfig(config)}
|
||||
disabled={isSaving}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{intl.formatMessage(i18n.certFingerprintHelp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||
<strong>Note:</strong> {intl.formatMessage(i18n.restartNote)}
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface ExternalGoosedConfig {
|
||||
enabled: boolean;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
certFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface StartGoosedOptions {
|
||||
|
||||
@@ -1136,6 +1136,15 @@
|
||||
"extensionsView.searchPlaceholder": {
|
||||
"defaultMessage": "Search extensions..."
|
||||
},
|
||||
"externalBackendSection.certFingerprint": {
|
||||
"defaultMessage": "Certificate Fingerprint (optional)"
|
||||
},
|
||||
"externalBackendSection.certFingerprintHelp": {
|
||||
"defaultMessage": "Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU)."
|
||||
},
|
||||
"externalBackendSection.certFingerprintPlaceholder": {
|
||||
"defaultMessage": "AA:BB:CC:... or sha256/base64"
|
||||
},
|
||||
"externalBackendSection.description": {
|
||||
"defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server"
|
||||
},
|
||||
|
||||
+43
-9
@@ -117,17 +117,26 @@ async function configureProxy() {
|
||||
|
||||
if (started) app.quit();
|
||||
|
||||
// Accept self-signed certificates from the local goosed server.
|
||||
// Certificate trust for goosed servers (local and external).
|
||||
// Both certificate-error (renderer) and setCertificateVerifyProc (main-process
|
||||
// net.fetch) pin to the exact cert fingerprint emitted by goosed at startup.
|
||||
// Before the fingerprint is available (during the health-check bootstrap
|
||||
// window) any localhost cert is accepted so the server can come up.
|
||||
// net.fetch) pin to the exact cert fingerprint. For locally-spawned goosed the
|
||||
// fingerprint comes from its stdout; for external backends we use Trust-On-First-Use
|
||||
// (TOFU) — the first TLS handshake pins the cert for the lifetime of the process.
|
||||
let pinnedCertFingerprint: string | null = null;
|
||||
|
||||
// Cached hostname of the configured external goosed server, updated when a
|
||||
// chat is created so we don't hit the filesystem on every TLS handshake.
|
||||
let trustedExternalHostname: string | null = null;
|
||||
|
||||
function isLocalhost(hostname: string): boolean {
|
||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
||||
}
|
||||
|
||||
function isTrustedHost(hostname: string): boolean {
|
||||
if (isLocalhost(hostname)) return true;
|
||||
return trustedExternalHostname !== null && hostname === trustedExternalHostname;
|
||||
}
|
||||
|
||||
function normalizeFingerprint(fp: string): string {
|
||||
if (fp.startsWith('sha256/')) {
|
||||
const b64 = fp.slice('sha256/'.length);
|
||||
@@ -145,7 +154,7 @@ function normalizeFingerprint(fp: string): string {
|
||||
// window) any localhost cert is accepted so the server can come up.
|
||||
app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => {
|
||||
const parsed = new URL(url);
|
||||
if (!isLocalhost(parsed.hostname)) {
|
||||
if (!isTrustedHost(parsed.hostname)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
@@ -155,6 +164,8 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call
|
||||
event.preventDefault();
|
||||
callback(match);
|
||||
} else {
|
||||
// TOFU: pin the certificate from the first successful handshake.
|
||||
pinnedCertFingerprint = normalizeFingerprint(certificate.fingerprint);
|
||||
event.preventDefault();
|
||||
callback(true);
|
||||
}
|
||||
@@ -163,17 +174,19 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call
|
||||
// Main-process net.fetch: pin to the exact cert goosed generated.
|
||||
app.whenReady().then(() => {
|
||||
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
||||
if (!isLocalhost(request.hostname)) {
|
||||
if (!isTrustedHost(request.hostname)) {
|
||||
callback(-3);
|
||||
return;
|
||||
}
|
||||
if (!pinnedCertFingerprint) {
|
||||
// TOFU: pin the certificate from the first successful handshake.
|
||||
pinnedCertFingerprint = normalizeFingerprint(request.certificate.fingerprint);
|
||||
callback(0);
|
||||
return;
|
||||
}
|
||||
const match =
|
||||
normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase();
|
||||
callback(match ? 0 : -3);
|
||||
callback(match ? 0 : -2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -574,6 +587,26 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
const settings = getSettings();
|
||||
const serverSecret = getServerSecret(settings);
|
||||
|
||||
// Update the cached trusted-external-hostname so the TLS handlers allow
|
||||
// connections to the configured remote backend.
|
||||
if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
|
||||
try {
|
||||
trustedExternalHostname = new URL(settings.externalGoosed.url).hostname;
|
||||
} catch {
|
||||
trustedExternalHostname = null;
|
||||
}
|
||||
} else {
|
||||
trustedExternalHostname = null;
|
||||
}
|
||||
|
||||
// If the user provided a cert fingerprint for the external backend, pin it
|
||||
// directly (skips TOFU). Otherwise reset so the first handshake pins via TOFU.
|
||||
if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) {
|
||||
pinnedCertFingerprint = normalizeFingerprint(settings.externalGoosed.certFingerprint);
|
||||
} else {
|
||||
pinnedCertFingerprint = null;
|
||||
}
|
||||
|
||||
const goosedResult = await startGoosed({
|
||||
serverSecret,
|
||||
dir: dir || os.homedir(),
|
||||
@@ -586,8 +619,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
||||
logger: log,
|
||||
});
|
||||
|
||||
// Pin the certificate fingerprint so the cert handlers above only accept
|
||||
// the exact cert that *this* goosed instance generated.
|
||||
// For locally-spawned goosed, pin using the fingerprint from stdout.
|
||||
// For external backends the TOFU path in the cert handlers will pin
|
||||
// the fingerprint on the first successful TLS handshake.
|
||||
if (goosedResult.certFingerprint) {
|
||||
pinnedCertFingerprint = goosedResult.certFingerprint;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface ExternalGoosedConfig {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secret: string;
|
||||
certFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcuts {
|
||||
|
||||
Reference in New Issue
Block a user