Default TLS validation on; scope skip-TLS bypass to the configured host

The app shipped with certificate validation bypassed globally and on by
default — InsecureTrustDelegate trusted any cert from any host. That was
the evaluation's P1: anyone on the LAN could MITM call audio, transcripts,
and voiceprints.

The backend's Start9 cert already validates under normal system trust when
the StartOS Root CA is installed in the keychain (confirmed: URLSession
default validation returns 200 against the backend and its fallback), so the
bypass is unnecessary:
- skip-TLS now defaults to off
- when explicitly enabled, the bypass is scoped to the configured host via
  InsecureTrustDelegate.allowsTrustOverride, never "trust any server"
- the host gate is pure and unit-tested (InsecureTrustDelegateTests)

Docs reconciled: AGENTS.md backend/TLS line and Current state.
This commit is contained in:
Grant Gilliam
2026-06-13 16:02:57 -05:00
parent 13a8972abb
commit 3629dbdaaa
7 changed files with 82 additions and 14 deletions
@@ -33,7 +33,9 @@ final class GatewayLLMClient {
config.timeoutIntervalForRequest = 600
config.timeoutIntervalForResource = 900
config.waitsForConnectivity = false
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
let delegate: URLSessionDelegate? = skipTLS
? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host)
: nil
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
@@ -1,19 +1,42 @@
import Foundation
/// URLSession delegate that trusts the server certificate without validation.
/// URLSession delegate that bypasses certificate validation for **one host only**
/// the configured SparkControl backend.
///
/// SparkControl sits behind a Start9 self-signed Root CA on the LAN, so default
/// trust evaluation rejects it. This delegate is used **only** when the
/// "Skip TLS verification" setting is on. It trusts any server certificate
/// acceptable for a personal tool on a trusted local network and nothing else.
/// SparkControl sits behind a Start9 self-signed Root CA on the LAN. The supported
/// path is to install that CA in the System keychain; default trust evaluation then
/// succeeds and this delegate is never used. It exists only as an opt-in escape
/// hatch (the "Skip TLS verification" setting, off by default) for a machine where
/// the CA isn't installed. Even then it trusts a certificate only when the challenge
/// host equals `allowedHost` a server-trust challenge from any other host falls
/// back to default validation, so the bypass can never become "trust any server".
final class InsecureTrustDelegate: NSObject, URLSessionDelegate {
/// The single host the bypass is scoped to (the configured backend host). When
/// nil only reachable via a malformed base URL the gate never fires and every
/// challenge falls back to default validation: the safe degenerate case.
private let allowedHost: String?
init(allowedHost: String?) {
self.allowedHost = allowedHost
}
/// The security gate: the trust override may fire only for a server-trust
/// challenge whose host matches `allowedHost`. Pure and synchronous so the
/// host-scoping can be unit-tested without fabricating a `SecTrust`; the
/// credential itself is built only when this is true *and* a serverTrust exists.
func allowsTrustOverride(for space: URLProtectionSpace) -> Bool {
guard let allowedHost else { return false }
return space.authenticationMethod == NSURLAuthenticationMethodServerTrust
&& space.host == allowedHost
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
allowsTrustOverride(for: challenge.protectionSpace),
let serverTrust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
@@ -82,7 +82,9 @@ final class SparkControlClient {
config.timeoutIntervalForRequest = 600 // diarization can take up to ~600s
config.timeoutIntervalForResource = 900
config.waitsForConnectivity = false
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
let delegate: URLSessionDelegate? = skipTLS
? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host)
: nil
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
@@ -32,7 +32,9 @@ final class SparkControlHealth: ObservableObject {
config.timeoutIntervalForRequest = 8
config.waitsForConnectivity = false
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
let delegate: URLSessionDelegate? = skipTLS
? InsecureTrustDelegate(allowedHost: url.host)
: nil
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }