feat: added certificate management de33418f
Steve · 2025-12-30 19:37 3 file(s) · +224 −14
Titan/Services/CertificateManager.swift (added) +121 −0
1 +
//
2 +
//  CertificateManager.swift
3 +
//  Titan
4 +
//
5 +
6 +
import Foundation
7 +
import CommonCrypto
8 +
import Security
9 +
10 +
struct StoredCertificate: Identifiable, Codable, Equatable {
11 +
    let id: UUID
12 +
    let hostname: String
13 +
    let fingerprint: String
14 +
    let commonName: String
15 +
    let firstSeen: Date
16 +
    var lastSeen: Date
17 +
18 +
    init(id: UUID = UUID(), hostname: String, fingerprint: String, commonName: String, firstSeen: Date = Date(), lastSeen: Date = Date()) {
19 +
        self.id = id
20 +
        self.hostname = hostname
21 +
        self.fingerprint = fingerprint
22 +
        self.commonName = commonName
23 +
        self.firstSeen = firstSeen
24 +
        self.lastSeen = lastSeen
25 +
    }
26 +
}
27 +
28 +
@Observable
29 +
class CertificateManager {
30 +
    private let storageKey = "titan_certificates"
31 +
32 +
    var certificates: [StoredCertificate] = []
33 +
34 +
    init() {
35 +
        loadCertificates()
36 +
    }
37 +
38 +
    func getStoredCertificate(for hostname: String) -> StoredCertificate? {
39 +
        certificates.first { $0.hostname == hostname }
40 +
    }
41 +
42 +
    func storeCertificate(hostname: String, fingerprint: String, commonName: String) {
43 +
        guard !certificates.contains(where: { $0.hostname == hostname }) else { return }
44 +
45 +
        let certificate = StoredCertificate(
46 +
            hostname: hostname,
47 +
            fingerprint: fingerprint,
48 +
            commonName: commonName
49 +
        )
50 +
        certificates.append(certificate)
51 +
        saveCertificates()
52 +
    }
53 +
54 +
    func updateCertificate(hostname: String, fingerprint: String, commonName: String) {
55 +
        if let index = certificates.firstIndex(where: { $0.hostname == hostname }) {
56 +
            let existing = certificates[index]
57 +
            certificates[index] = StoredCertificate(
58 +
                id: existing.id,
59 +
                hostname: hostname,
60 +
                fingerprint: fingerprint,
61 +
                commonName: commonName,
62 +
                firstSeen: existing.firstSeen,
63 +
                lastSeen: Date()
64 +
            )
65 +
        } else {
66 +
            storeCertificate(hostname: hostname, fingerprint: fingerprint, commonName: commonName)
67 +
        }
68 +
        saveCertificates()
69 +
    }
70 +
71 +
    func updateLastSeen(for hostname: String) {
72 +
        if let index = certificates.firstIndex(where: { $0.hostname == hostname }) {
73 +
            var cert = certificates[index]
74 +
            cert.lastSeen = Date()
75 +
            certificates[index] = cert
76 +
            saveCertificates()
77 +
        }
78 +
    }
79 +
80 +
    func removeCertificate(for hostname: String) {
81 +
        certificates.removeAll { $0.hostname == hostname }
82 +
        saveCertificates()
83 +
    }
84 +
85 +
    private func saveCertificates() {
86 +
        if let data = try? JSONEncoder().encode(certificates) {
87 +
            UserDefaults.standard.set(data, forKey: storageKey)
88 +
        }
89 +
    }
90 +
91 +
    private func loadCertificates() {
92 +
        guard let data = UserDefaults.standard.data(forKey: storageKey),
93 +
              let decoded = try? JSONDecoder().decode([StoredCertificate].self, from: data) else {
94 +
            return
95 +
        }
96 +
        certificates = decoded
97 +
    }
98 +
99 +
    // MARK: - Certificate Extraction Helpers
100 +
101 +
    static func extractCertificateInfo(from secTrust: SecTrust) -> (fingerprint: String, commonName: String)? {
102 +
        guard let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate],
103 +
              let leafCert = chain.first else {
104 +
            return nil
105 +
        }
106 +
107 +
        let certData = SecCertificateCopyData(leafCert) as Data
108 +
        let fingerprint = sha256Fingerprint(certData)
109 +
        let commonName = SecCertificateCopySubjectSummary(leafCert) as String? ?? "Unknown"
110 +
111 +
        return (fingerprint, commonName)
112 +
    }
113 +
114 +
    static func sha256Fingerprint(_ data: Data) -> String {
115 +
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
116 +
        data.withUnsafeBytes {
117 +
            _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
118 +
        }
119 +
        return hash.map { String(format: "%02x", $0) }.joined(separator: ":")
120 +
    }
121 +
}
Titan/Services/GeminiClient.swift +56 −12
38 38
    case invalidResponse
39 39
    case invalidURL
40 40
    case cancelled
41 +
    case certificateMismatch(hostname: String, storedFingerprint: String, newFingerprint: String, commonName: String)
42 +
    case certificateError
41 43
42 44
    var errorDescription: String? {
43 45
        switch self {
44 46
        case .invalidResponse: return "Invalid response from server"
45 47
        case .invalidURL: return "Invalid URL"
46 48
        case .cancelled: return "Request cancelled"
49 +
        case .certificateMismatch(let hostname, _, _, _):
50 +
            return "Certificate for \(hostname) has changed"
51 +
        case .certificateError: return "Could not verify server certificate"
47 52
        }
48 53
    }
49 54
}
51 56
// MARK: - Client
52 57
53 58
class GeminiClient {
54 -
    let rejectUnauthorized: Bool
55 -
    
56 -
    init(rejectUnauthorized: Bool = true) {
57 -
        self.rejectUnauthorized = rejectUnauthorized
59 +
    let certificateManager: CertificateManager
60 +
61 +
    init(certificateManager: CertificateManager) {
62 +
        self.certificateManager = certificateManager
58 63
    }
59 -
    
64 +
60 65
    func connect(
61 66
        hostname: String,
62 67
        port: Int = 1965,
66 71
        let port = NWEndpoint.Port(integerLiteral: UInt16(port))
67 72
68 73
        let tlsOptions = NWProtocolTLS.Options()
69 -
        let rejectUnauthorized = self.rejectUnauthorized  // Capture the value, not self
74 +
        let certVerificationState = CertificateVerificationState()
75 +
        let capturedHostname = hostname
76 +
        let capturedCertManager = self.certificateManager
70 77
71 78
        sec_protocol_options_set_verify_block(
72 79
            tlsOptions.securityProtocolOptions,
73 80
            { _, trust, verify_complete in
74 -
                if rejectUnauthorized {
75 -
                    var error: CFError?
76 -
                    let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
77 -
                    let result = SecTrustEvaluateWithError(secTrust, &error)
78 -
                    verify_complete(result)
81 +
                let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
82 +
83 +
                guard let certInfo = CertificateManager.extractCertificateInfo(from: secTrust) else {
84 +
                    certVerificationState.error = .certificateError
85 +
                    verify_complete(false)
86 +
                    return
87 +
                }
88 +
89 +
                let storedCert = capturedCertManager.getStoredCertificate(for: capturedHostname)
90 +
91 +
                if let stored = storedCert {
92 +
                    if stored.fingerprint == certInfo.fingerprint {
93 +
                        // Certificate matches - allow connection
94 +
                        capturedCertManager.updateLastSeen(for: capturedHostname)
95 +
                        verify_complete(true)
96 +
                    } else {
97 +
                        // Certificate mismatch - reject and store details
98 +
                        certVerificationState.error = .certificateMismatch(
99 +
                            hostname: capturedHostname,
100 +
                            storedFingerprint: stored.fingerprint,
101 +
                            newFingerprint: certInfo.fingerprint,
102 +
                            commonName: certInfo.commonName
103 +
                        )
104 +
                        verify_complete(false)
105 +
                    }
79 106
                } else {
107 +
                    // First visit - trust and store (TOFU)
108 +
                    capturedCertManager.storeCertificate(
109 +
                        hostname: capturedHostname,
110 +
                        fingerprint: certInfo.fingerprint,
111 +
                        commonName: certInfo.commonName
112 +
                    )
113 +
                    print("🔐 Trusted new certificate for \(capturedHostname)")
80 114
                    verify_complete(true)
81 115
                }
82 116
            },
116 150
                        connection.cancel()
117 151
                        if !state.continuationResumed {
118 152
                            state.continuationResumed = true
119 -
                            continuation.resume(throwing: error)
153 +
                            // Check if this was a certificate verification error
154 +
                            if let certError = certVerificationState.error {
155 +
                                continuation.resume(throwing: certError)
156 +
                            } else {
157 +
                                continuation.resume(throwing: error)
158 +
                            }
120 159
                        }
121 160
122 161
                    case .cancelled:
211 250
        var responseData: Data {
212 251
            chunks.reduce(Data(), +)
213 252
        }
253 +
    }
254 +
255 +
    // Helper class to manage certificate verification state
256 +
    private class CertificateVerificationState: @unchecked Sendable {
257 +
        var error: GeminiError?
214 258
    }
215 259
}
Titan/Views/ContentView.swift +47 −2
50 50
    // Error state
51 51
    @State private var currentError: GeminiErrorType?
52 52
53 +
    // Certificate management
54 +
    @State private var certificateManager = CertificateManager()
55 +
    @State private var showCertificateMismatchAlert = false
56 +
    @State private var pendingCertificateChange: (hostname: String, storedFingerprint: String, newFingerprint: String, commonName: String)?
57 +
    @State private var pendingCertificateURL: String?
58 +
53 59
    private let maxRedirects = 5
54 60
55 61
    var body: some View {
165 171
                loadActiveTabState()
166 172
            }
167 173
        }
174 +
        .alert("Certificate Changed", isPresented: $showCertificateMismatchAlert) {
175 +
            Button("Reject", role: .cancel) {
176 +
                pendingCertificateChange = nil
177 +
                pendingCertificateURL = nil
178 +
            }
179 +
            Button("Accept New Certificate") {
180 +
                if let change = pendingCertificateChange {
181 +
                    certificateManager.updateCertificate(
182 +
                        hostname: change.hostname,
183 +
                        fingerprint: change.newFingerprint,
184 +
                        commonName: change.commonName
185 +
                    )
186 +
                    // Retry navigation with the updated certificate
187 +
                    if let url = pendingCertificateURL {
188 +
                        navigateTo(url)
189 +
                    }
190 +
                }
191 +
                pendingCertificateChange = nil
192 +
                pendingCertificateURL = nil
193 +
            }
194 +
        } message: {
195 +
            if let change = pendingCertificateChange {
196 +
                Text("The certificate for \(change.hostname) has changed.\n\nOld fingerprint:\n\(formatFingerprint(change.storedFingerprint))\n\nNew fingerprint:\n\(formatFingerprint(change.newFingerprint))\n\nThis could indicate a security issue or the server updated its certificate.")
197 +
            } else {
198 +
                Text("A certificate change was detected.")
199 +
            }
200 +
        }
201 +
    }
202 +
203 +
    private func formatFingerprint(_ fingerprint: String) -> String {
204 +
        fingerprint.prefix(32) + "..."
168 205
    }
169 206
170 207
    // MARK: - Tab State Management
370 407
            } catch is CancellationError {
371 408
                // Task was cancelled, don't update UI
372 409
                return
373 -
            } catch let error as GeminiError where error == .cancelled {
410 +
            } catch GeminiError.cancelled {
374 411
                // Request was cancelled, don't update UI
375 412
                return
376 413
            } catch let error as GeminiError {
381 418
                    currentError = .invalidURL
382 419
                case .cancelled:
383 420
                    return
421 +
                case .certificateMismatch(let hostname, let storedFingerprint, let newFingerprint, let commonName):
422 +
                    pendingCertificateChange = (hostname, storedFingerprint, newFingerprint, commonName)
423 +
                    pendingCertificateURL = urlText
424 +
                    showCertificateMismatchAlert = true
425 +
                    isLoading = false
426 +
                    return
427 +
                case .certificateError:
428 +
                    currentError = .networkError("Could not verify server certificate")
384 429
                }
385 430
                if addToHistory {
386 431
                    addToNavigationHistory(url: urlText)
405 450
            throw GeminiError.invalidURL
406 451
        }
407 452
408 -
        let client = GeminiClient(rejectUnauthorized: false)
453 +
        let client = GeminiClient(certificateManager: certificateManager)
409 454
        let port = url.port ?? 1965
410 455
        let response = try await client.connect(
411 456
            hostname: host,