feat: added client certificates and management e3835bdb
Steve · 2026-01-06 19:51 7 file(s) · +1344 −13
Titan/Services/ClientCertificateManager.swift (added) +409 −0
1 +
//
2 +
//  ClientCertificateManager.swift
3 +
//  Titan
4 +
//
5 +
//  Manages client certificates for Gemini authentication
6 +
//
7 +
8 +
import Foundation
9 +
import Security
10 +
import CommonCrypto
11 +
12 +
// MARK: - Data Models
13 +
14 +
struct ClientCertificate: Identifiable, Codable, Equatable {
15 +
    let id: UUID
16 +
    let name: String
17 +
    let fingerprint: String
18 +
    let createdAt: Date
19 +
    let expiresAt: Date
20 +
21 +
    init(id: UUID = UUID(), name: String, fingerprint: String, createdAt: Date = Date(), expiresAt: Date) {
22 +
        self.id = id
23 +
        self.name = name
24 +
        self.fingerprint = fingerprint
25 +
        self.createdAt = createdAt
26 +
        self.expiresAt = expiresAt
27 +
    }
28 +
29 +
    var isExpired: Bool {
30 +
        Date() > expiresAt
31 +
    }
32 +
33 +
    var daysUntilExpiry: Int {
34 +
        Calendar.current.dateComponents([.day], from: Date(), to: expiresAt).day ?? 0
35 +
    }
36 +
}
37 +
38 +
struct CertificateHostAssociation: Codable, Equatable, Identifiable {
39 +
    var id: String { "\(host):\(port)\(pathPrefix)" }
40 +
    let certificateId: UUID
41 +
    let host: String
42 +
    let port: Int
43 +
    let pathPrefix: String
44 +
    let createdAt: Date
45 +
46 +
    init(certificateId: UUID, host: String, port: Int = 1965, pathPrefix: String = "/", createdAt: Date = Date()) {
47 +
        self.certificateId = certificateId
48 +
        self.host = host
49 +
        self.port = port
50 +
        self.pathPrefix = pathPrefix
51 +
        self.createdAt = createdAt
52 +
    }
53 +
}
54 +
55 +
// MARK: - Errors
56 +
57 +
enum ClientCertificateError: LocalizedError {
58 +
    case generationFailed(Error)
59 +
    case keychainStoreFailed(OSStatus)
60 +
    case keychainDeleteFailed(OSStatus)
61 +
    case identityNotFound
62 +
    case certificateCreationFailed
63 +
64 +
    var errorDescription: String? {
65 +
        switch self {
66 +
        case .generationFailed(let error):
67 +
            return "Certificate generation failed: \(error.localizedDescription)"
68 +
        case .keychainStoreFailed(let status):
69 +
            return "Failed to store in Keychain (status: \(status))"
70 +
        case .keychainDeleteFailed(let status):
71 +
            return "Failed to delete from Keychain (status: \(status))"
72 +
        case .identityNotFound:
73 +
            return "Certificate identity not found in Keychain"
74 +
        case .certificateCreationFailed:
75 +
            return "Failed to create certificate from data"
76 +
        }
77 +
    }
78 +
}
79 +
80 +
// MARK: - Manager
81 +
82 +
@Observable
83 +
class ClientCertificateManager {
84 +
    private let certificatesStorageKey = "titan_client_certificates"
85 +
    private let associationsStorageKey = "titan_client_cert_associations"
86 +
    private let keychainTagPrefix = "com.titan.clientcert"
87 +
88 +
    var certificates: [ClientCertificate] = []
89 +
    var associations: [CertificateHostAssociation] = []
90 +
91 +
    init() {
92 +
        loadData()
93 +
        cleanupOrphanedAssociations()
94 +
    }
95 +
96 +
    // MARK: - Certificate Lifecycle
97 +
98 +
    /// Creates a new client certificate with the given friendly name
99 +
    func createCertificate(name: String, validityDays: Int = 365) throws -> ClientCertificate {
100 +
        // Generate the certificate
101 +
        let (certificateData, privateKey) = try X509Generator.generateSelfSignedCertificate(
102 +
            commonName: name,
103 +
            validityDays: validityDays
104 +
        )
105 +
106 +
        // Create SecCertificate from data
107 +
        guard let secCertificate = SecCertificateCreateWithData(nil, certificateData as CFData) else {
108 +
            throw ClientCertificateError.certificateCreationFailed
109 +
        }
110 +
111 +
        let id = UUID()
112 +
        let fingerprint = CertificateManager.sha256Fingerprint(certificateData)
113 +
        let expiresAt = Calendar.current.date(byAdding: .day, value: validityDays, to: Date())!
114 +
115 +
        // Store in Keychain
116 +
        try storeInKeychain(
117 +
            id: id,
118 +
            certificate: secCertificate,
119 +
            privateKey: privateKey
120 +
        )
121 +
122 +
        // Create metadata
123 +
        let certificate = ClientCertificate(
124 +
            id: id,
125 +
            name: name,
126 +
            fingerprint: fingerprint,
127 +
            createdAt: Date(),
128 +
            expiresAt: expiresAt
129 +
        )
130 +
131 +
        certificates.append(certificate)
132 +
        saveData()
133 +
134 +
        print("🔐 Created client certificate: \(name)")
135 +
        return certificate
136 +
    }
137 +
138 +
    /// Deletes a certificate and all its associations
139 +
    func deleteCertificate(id: UUID) throws {
140 +
        // Remove from Keychain
141 +
        try deleteFromKeychain(id: id)
142 +
143 +
        // Remove metadata
144 +
        certificates.removeAll { $0.id == id }
145 +
146 +
        // Remove all associations
147 +
        associations.removeAll { $0.certificateId == id }
148 +
149 +
        saveData()
150 +
        print("🗑️ Deleted client certificate: \(id)")
151 +
    }
152 +
153 +
    /// Gets a certificate by ID
154 +
    func getCertificate(id: UUID) -> ClientCertificate? {
155 +
        certificates.first { $0.id == id }
156 +
    }
157 +
158 +
    // MARK: - Host Associations
159 +
160 +
    /// Associates a certificate with a URL (host + port + path prefix)
161 +
    func associateCertificate(certificateId: UUID, with url: URL) {
162 +
        let host = url.host ?? ""
163 +
        let port = url.port ?? 1965
164 +
        let pathPrefix = normalizePathPrefix(url.path)
165 +
166 +
        // Remove any existing association for this exact path
167 +
        associations.removeAll { $0.host == host && $0.port == port && $0.pathPrefix == pathPrefix }
168 +
169 +
        let association = CertificateHostAssociation(
170 +
            certificateId: certificateId,
171 +
            host: host,
172 +
            port: port,
173 +
            pathPrefix: pathPrefix
174 +
        )
175 +
176 +
        associations.append(association)
177 +
        saveData()
178 +
179 +
        print("🔗 Associated certificate with \(host):\(port)\(pathPrefix)")
180 +
    }
181 +
182 +
    /// Removes a specific association
183 +
    func removeAssociation(host: String, port: Int, pathPrefix: String) {
184 +
        associations.removeAll { $0.host == host && $0.port == port && $0.pathPrefix == pathPrefix }
185 +
        saveData()
186 +
    }
187 +
188 +
    /// Gets all associations for a certificate
189 +
    func getAssociationsForCertificate(id: UUID) -> [CertificateHostAssociation] {
190 +
        associations.filter { $0.certificateId == id }
191 +
    }
192 +
193 +
    /// Finds the best matching certificate for a URL
194 +
    /// Returns the SecIdentity if found, nil otherwise
195 +
    func findIdentity(for url: URL) -> SecIdentity? {
196 +
        guard let certificateId = findCertificateId(for: url) else {
197 +
            return nil
198 +
        }
199 +
        return getSecIdentity(for: certificateId)
200 +
    }
201 +
202 +
    /// Finds the certificate ID for a URL (longest path prefix match)
203 +
    func findCertificateId(for url: URL) -> UUID? {
204 +
        let host = url.host ?? ""
205 +
        let port = url.port ?? 1965
206 +
        let path = url.path.isEmpty ? "/" : url.path
207 +
208 +
        // Find all matching associations
209 +
        let matching = associations
210 +
            .filter { $0.host == host && $0.port == port }
211 +
            .filter { path.hasPrefix($0.pathPrefix) }
212 +
            .sorted { $0.pathPrefix.count > $1.pathPrefix.count }
213 +
214 +
        return matching.first?.certificateId
215 +
    }
216 +
217 +
    /// Checks if a URL has an associated certificate
218 +
    func hasAssociation(for url: URL) -> Bool {
219 +
        findCertificateId(for: url) != nil
220 +
    }
221 +
222 +
    // MARK: - Keychain Operations
223 +
224 +
    /// Retrieves SecIdentity from Keychain
225 +
    func getSecIdentity(for certificateId: UUID) -> SecIdentity? {
226 +
        let tag = keychainTag(for: certificateId)
227 +
228 +
        // First get the certificate
229 +
        let certQuery: [String: Any] = [
230 +
            kSecClass as String: kSecClassCertificate,
231 +
            kSecAttrLabel as String: tag,
232 +
            kSecReturnRef as String: true
233 +
        ]
234 +
235 +
        var certRef: CFTypeRef?
236 +
        let certStatus = SecItemCopyMatching(certQuery as CFDictionary, &certRef)
237 +
238 +
        guard certStatus == errSecSuccess, let certificate = certRef else {
239 +
            print("❌ Certificate not found in Keychain for \(certificateId)")
240 +
            return nil
241 +
        }
242 +
243 +
        // Then get the private key
244 +
        let keyQuery: [String: Any] = [
245 +
            kSecClass as String: kSecClassKey,
246 +
            kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
247 +
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
248 +
            kSecReturnRef as String: true
249 +
        ]
250 +
251 +
        var keyRef: CFTypeRef?
252 +
        let keyStatus = SecItemCopyMatching(keyQuery as CFDictionary, &keyRef)
253 +
254 +
        guard keyStatus == errSecSuccess, let privateKey = keyRef else {
255 +
            print("❌ Private key not found in Keychain for \(certificateId)")
256 +
            return nil
257 +
        }
258 +
259 +
        // Create identity from certificate and key
260 +
        // Note: SecIdentityCreateWithCertificate is not available on iOS
261 +
        // We need to use a different approach - store as identity directly
262 +
        // or use SecPKCS12Import approach
263 +
264 +
        // For iOS, we'll return a constructed identity using our stored items
265 +
        // The Network framework's sec_identity_create can work with (cert, key) pairs
266 +
        // But we need SecIdentity for that API
267 +
268 +
        // Alternative: Query for identity directly (requires items to be stored as identity)
269 +
        let identityQuery: [String: Any] = [
270 +
            kSecClass as String: kSecClassIdentity,
271 +
            kSecAttrLabel as String: tag,
272 +
            kSecReturnRef as String: true
273 +
        ]
274 +
275 +
        var identityRef: CFTypeRef?
276 +
        let identityStatus = SecItemCopyMatching(identityQuery as CFDictionary, &identityRef)
277 +
278 +
        if identityStatus == errSecSuccess, let identity = identityRef {
279 +
            return (identity as! SecIdentity)
280 +
        }
281 +
282 +
        print("❌ Identity not found in Keychain for \(certificateId)")
283 +
        return nil
284 +
    }
285 +
286 +
    private func storeInKeychain(id: UUID, certificate: SecCertificate, privateKey: SecKey) throws {
287 +
        let tag = keychainTag(for: id)
288 +
        let tagData = tag.data(using: .utf8)!
289 +
290 +
        // First, store the private key
291 +
        let keyQuery: [String: Any] = [
292 +
            kSecClass as String: kSecClassKey,
293 +
            kSecAttrApplicationTag as String: tagData,
294 +
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
295 +
            kSecValueRef as String: privateKey,
296 +
            kSecAttrIsPermanent as String: true,
297 +
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
298 +
            kSecAttrLabel as String: tag
299 +
        ]
300 +
301 +
        var keyStatus = SecItemAdd(keyQuery as CFDictionary, nil)
302 +
        if keyStatus == errSecDuplicateItem {
303 +
            // Delete and retry
304 +
            SecItemDelete(keyQuery as CFDictionary)
305 +
            keyStatus = SecItemAdd(keyQuery as CFDictionary, nil)
306 +
        }
307 +
308 +
        if keyStatus != errSecSuccess {
309 +
            throw ClientCertificateError.keychainStoreFailed(keyStatus)
310 +
        }
311 +
312 +
        // Then store the certificate
313 +
        let certQuery: [String: Any] = [
314 +
            kSecClass as String: kSecClassCertificate,
315 +
            kSecValueRef as String: certificate,
316 +
            kSecAttrLabel as String: tag,
317 +
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
318 +
        ]
319 +
320 +
        var certStatus = SecItemAdd(certQuery as CFDictionary, nil)
321 +
        if certStatus == errSecDuplicateItem {
322 +
            SecItemDelete(certQuery as CFDictionary)
323 +
            certStatus = SecItemAdd(certQuery as CFDictionary, nil)
324 +
        }
325 +
326 +
        if certStatus != errSecSuccess {
327 +
            // Clean up the key we just added
328 +
            SecItemDelete(keyQuery as CFDictionary)
329 +
            throw ClientCertificateError.keychainStoreFailed(certStatus)
330 +
        }
331 +
332 +
        // The identity should be automatically created by the Keychain
333 +
        // when a certificate and its matching private key are both stored
334 +
        print("✅ Stored certificate and key in Keychain with tag: \(tag)")
335 +
    }
336 +
337 +
    private func deleteFromKeychain(id: UUID) throws {
338 +
        let tag = keychainTag(for: id)
339 +
        let tagData = tag.data(using: .utf8)!
340 +
341 +
        // Delete certificate
342 +
        let certQuery: [String: Any] = [
343 +
            kSecClass as String: kSecClassCertificate,
344 +
            kSecAttrLabel as String: tag
345 +
        ]
346 +
        SecItemDelete(certQuery as CFDictionary)
347 +
348 +
        // Delete private key
349 +
        let keyQuery: [String: Any] = [
350 +
            kSecClass as String: kSecClassKey,
351 +
            kSecAttrApplicationTag as String: tagData
352 +
        ]
353 +
        SecItemDelete(keyQuery as CFDictionary)
354 +
355 +
        // Delete identity (if exists)
356 +
        let identityQuery: [String: Any] = [
357 +
            kSecClass as String: kSecClassIdentity,
358 +
            kSecAttrLabel as String: tag
359 +
        ]
360 +
        SecItemDelete(identityQuery as CFDictionary)
361 +
    }
362 +
363 +
    private func keychainTag(for id: UUID) -> String {
364 +
        "\(keychainTagPrefix).\(id.uuidString)"
365 +
    }
366 +
367 +
    // MARK: - Persistence
368 +
369 +
    private func saveData() {
370 +
        if let certData = try? JSONEncoder().encode(certificates) {
371 +
            UserDefaults.standard.set(certData, forKey: certificatesStorageKey)
372 +
        }
373 +
        if let assocData = try? JSONEncoder().encode(associations) {
374 +
            UserDefaults.standard.set(assocData, forKey: associationsStorageKey)
375 +
        }
376 +
    }
377 +
378 +
    private func loadData() {
379 +
        if let data = UserDefaults.standard.data(forKey: certificatesStorageKey),
380 +
           let decoded = try? JSONDecoder().decode([ClientCertificate].self, from: data) {
381 +
            certificates = decoded
382 +
        }
383 +
        if let data = UserDefaults.standard.data(forKey: associationsStorageKey),
384 +
           let decoded = try? JSONDecoder().decode([CertificateHostAssociation].self, from: data) {
385 +
            associations = decoded
386 +
        }
387 +
    }
388 +
389 +
    private func cleanupOrphanedAssociations() {
390 +
        let validIds = Set(certificates.map { $0.id })
391 +
        let before = associations.count
392 +
        associations.removeAll { !validIds.contains($0.certificateId) }
393 +
        if associations.count != before {
394 +
            saveData()
395 +
            print("🧹 Cleaned up \(before - associations.count) orphaned associations")
396 +
        }
397 +
    }
398 +
399 +
    // MARK: - Helpers
400 +
401 +
    private func normalizePathPrefix(_ path: String) -> String {
402 +
        var normalized = path.isEmpty ? "/" : path
403 +
        // Remove trailing slash unless it's the root
404 +
        if normalized.count > 1 && normalized.hasSuffix("/") {
405 +
            normalized.removeLast()
406 +
        }
407 +
        return normalized
408 +
    }
409 +
}
Titan/Services/GeminiClient.swift +14 −1
65 65
    func connect(
66 66
        hostname: String,
67 67
        port: Int = 1965,
68 -
        urlString: String
68 +
        urlString: String,
69 +
        clientIdentity: SecIdentity? = nil
69 70
    ) async throws -> GeminiResponse {
70 71
        let host = NWEndpoint.Host(hostname)
71 72
        let port = NWEndpoint.Port(integerLiteral: UInt16(port))
116 117
            },
117 118
            DispatchQueue.main
118 119
        )
120 +
121 +
        // Set client certificate identity if provided
122 +
        if let identity = clientIdentity {
123 +
            if let secIdentity = sec_identity_create(identity) {
124 +
                sec_protocol_options_set_local_identity(
125 +
                    tlsOptions.securityProtocolOptions,
126 +
                    secIdentity
127 +
                )
128 +
                print("🔐 Using client certificate for authentication")
129 +
            }
130 +
        }
131 +
119 132
        let parameters = NWParameters(tls: tlsOptions)
120 133
        let connection = NWConnection(host: host, port: port, using: parameters)
121 134
        let state = ConnectionState()
Titan/Services/X509Generator.swift (added) +343 −0
1 +
//
2 +
//  X509Generator.swift
3 +
//  Titan
4 +
//
5 +
//  Self-signed X.509 certificate generation using Security framework
6 +
//
7 +
8 +
import Foundation
9 +
import Security
10 +
11 +
enum X509GeneratorError: LocalizedError {
12 +
    case keyGenerationFailed(OSStatus)
13 +
    case publicKeyExtractionFailed
14 +
    case signingFailed
15 +
    case invalidKeyData
16 +
17 +
    var errorDescription: String? {
18 +
        switch self {
19 +
        case .keyGenerationFailed(let status):
20 +
            return "Key generation failed with status: \(status)"
21 +
        case .publicKeyExtractionFailed:
22 +
            return "Failed to extract public key data"
23 +
        case .signingFailed:
24 +
            return "Failed to sign certificate"
25 +
        case .invalidKeyData:
26 +
            return "Invalid key data format"
27 +
        }
28 +
    }
29 +
}
30 +
31 +
struct X509Generator {
32 +
33 +
    /// Generates a self-signed X.509 certificate with EC P-256 key
34 +
    /// - Parameters:
35 +
    ///   - commonName: The CN field for the certificate (typically the user's friendly name)
36 +
    ///   - validityDays: Number of days the certificate is valid (default 365)
37 +
    /// - Returns: Tuple of (DER-encoded certificate data, private key)
38 +
    static func generateSelfSignedCertificate(
39 +
        commonName: String,
40 +
        validityDays: Int = 365
41 +
    ) throws -> (certificateData: Data, privateKey: SecKey) {
42 +
        // Generate EC P-256 key pair
43 +
        let privateKey = try generateECKeyPair()
44 +
45 +
        guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
46 +
            throw X509GeneratorError.publicKeyExtractionFailed
47 +
        }
48 +
49 +
        // Build the TBS (to-be-signed) certificate
50 +
        let tbsCertificate = try buildTBSCertificate(
51 +
            commonName: commonName,
52 +
            publicKey: publicKey,
53 +
            validityDays: validityDays
54 +
        )
55 +
56 +
        // Sign the TBS certificate
57 +
        let signature = try signData(tbsCertificate, with: privateKey)
58 +
59 +
        // Build the complete certificate
60 +
        let certificate = buildCertificate(
61 +
            tbsCertificate: tbsCertificate,
62 +
            signature: signature
63 +
        )
64 +
65 +
        return (certificate, privateKey)
66 +
    }
67 +
68 +
    // MARK: - Key Generation
69 +
70 +
    private static func generateECKeyPair() throws -> SecKey {
71 +
        let attributes: [String: Any] = [
72 +
            kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
73 +
            kSecAttrKeySizeInBits as String: 256,
74 +
            kSecAttrIsPermanent as String: false
75 +
        ]
76 +
77 +
        var error: Unmanaged<CFError>?
78 +
        guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
79 +
            throw X509GeneratorError.keyGenerationFailed(-1)
80 +
        }
81 +
82 +
        return privateKey
83 +
    }
84 +
85 +
    // MARK: - Certificate Building
86 +
87 +
    private static func buildTBSCertificate(
88 +
        commonName: String,
89 +
        publicKey: SecKey,
90 +
        validityDays: Int
91 +
    ) throws -> Data {
92 +
        var tbs = Data()
93 +
94 +
        // Version: [0] EXPLICIT INTEGER { 2 } (v3)
95 +
        tbs.append(contentsOf: DER.contextTag(0, explicit: true, contents: DER.integer(2)))
96 +
97 +
        // Serial Number: Random positive integer
98 +
        let serialNumber = generateSerialNumber()
99 +
        tbs.append(contentsOf: DER.integer(serialNumber))
100 +
101 +
        // Signature Algorithm: ecdsa-with-SHA256 (1.2.840.10045.4.3.2)
102 +
        tbs.append(contentsOf: DER.sequence([
103 +
            DER.oid([1, 2, 840, 10045, 4, 3, 2])
104 +
        ]))
105 +
106 +
        // Issuer: CN=commonName
107 +
        let issuer = DER.rdnSequence(commonName: commonName)
108 +
        tbs.append(contentsOf: issuer)
109 +
110 +
        // Validity
111 +
        let now = Date()
112 +
        let expiry = Calendar.current.date(byAdding: .day, value: validityDays, to: now)!
113 +
        tbs.append(contentsOf: DER.sequence([
114 +
            DER.utcTime(now),
115 +
            DER.utcTime(expiry)
116 +
        ]))
117 +
118 +
        // Subject: Same as issuer (self-signed)
119 +
        tbs.append(contentsOf: issuer)
120 +
121 +
        // Subject Public Key Info
122 +
        let publicKeyInfo = try buildSubjectPublicKeyInfo(publicKey: publicKey)
123 +
        tbs.append(contentsOf: publicKeyInfo)
124 +
125 +
        return DER.sequence(tbs)
126 +
    }
127 +
128 +
    private static func buildSubjectPublicKeyInfo(publicKey: SecKey) throws -> Data {
129 +
        guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
130 +
            throw X509GeneratorError.publicKeyExtractionFailed
131 +
        }
132 +
133 +
        // Algorithm: id-ecPublicKey (1.2.840.10045.2.1) with prime256v1 (1.2.840.10045.3.1.7)
134 +
        let algorithm = DER.sequence([
135 +
            DER.oid([1, 2, 840, 10045, 2, 1]),  // id-ecPublicKey
136 +
            DER.oid([1, 2, 840, 10045, 3, 1, 7]) // prime256v1 (P-256)
137 +
        ])
138 +
139 +
        // Public key is already in uncompressed point format (0x04 || x || y)
140 +
        let publicKeyBitString = DER.bitString(publicKeyData)
141 +
142 +
        return DER.sequence([algorithm, publicKeyBitString])
143 +
    }
144 +
145 +
    private static func buildCertificate(tbsCertificate: Data, signature: Data) -> Data {
146 +
        // Signature Algorithm: ecdsa-with-SHA256
147 +
        let signatureAlgorithm = DER.sequence([
148 +
            DER.oid([1, 2, 840, 10045, 4, 3, 2])
149 +
        ])
150 +
151 +
        // Signature Value (BIT STRING)
152 +
        let signatureValue = DER.bitString(signature)
153 +
154 +
        return DER.sequence([tbsCertificate, signatureAlgorithm, signatureValue])
155 +
    }
156 +
157 +
    // MARK: - Signing
158 +
159 +
    private static func signData(_ data: Data, with privateKey: SecKey) throws -> Data {
160 +
        var error: Unmanaged<CFError>?
161 +
        guard let signature = SecKeyCreateSignature(
162 +
            privateKey,
163 +
            .ecdsaSignatureMessageX962SHA256,
164 +
            data as CFData,
165 +
            &error
166 +
        ) as Data? else {
167 +
            throw X509GeneratorError.signingFailed
168 +
        }
169 +
170 +
        return signature
171 +
    }
172 +
173 +
    // MARK: - Helpers
174 +
175 +
    private static func generateSerialNumber() -> [UInt8] {
176 +
        // Generate a 16-byte random serial number
177 +
        var bytes = [UInt8](repeating: 0, count: 16)
178 +
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
179 +
        // Ensure it's positive by clearing the high bit
180 +
        bytes[0] &= 0x7F
181 +
        // Ensure it's non-zero
182 +
        if bytes.allSatisfy({ $0 == 0 }) {
183 +
            bytes[15] = 1
184 +
        }
185 +
        return bytes
186 +
    }
187 +
}
188 +
189 +
// MARK: - DER Encoding
190 +
191 +
private enum DER {
192 +
    // ASN.1 tag constants
193 +
    static let tagInteger: UInt8 = 0x02
194 +
    static let tagBitString: UInt8 = 0x03
195 +
    static let tagOctetString: UInt8 = 0x04
196 +
    static let tagNull: UInt8 = 0x05
197 +
    static let tagOID: UInt8 = 0x06
198 +
    static let tagUTF8String: UInt8 = 0x0C
199 +
    static let tagSequence: UInt8 = 0x30
200 +
    static let tagSet: UInt8 = 0x31
201 +
    static let tagUTCTime: UInt8 = 0x17
202 +
    static let tagGeneralizedTime: UInt8 = 0x18
203 +
204 +
    static func sequence(_ contents: Data) -> Data {
205 +
        return encode(tag: tagSequence, contents: contents)
206 +
    }
207 +
208 +
    static func sequence(_ elements: [Data]) -> Data {
209 +
        let contents = elements.reduce(Data()) { $0 + $1 }
210 +
        return encode(tag: tagSequence, contents: contents)
211 +
    }
212 +
213 +
    static func set(_ elements: [Data]) -> Data {
214 +
        let contents = elements.reduce(Data()) { $0 + $1 }
215 +
        return encode(tag: tagSet, contents: contents)
216 +
    }
217 +
218 +
    static func integer(_ value: Int) -> Data {
219 +
        var bytes = [UInt8]()
220 +
        var v = value
221 +
222 +
        if v == 0 {
223 +
            bytes = [0]
224 +
        } else {
225 +
            while v > 0 {
226 +
                bytes.insert(UInt8(v & 0xFF), at: 0)
227 +
                v >>= 8
228 +
            }
229 +
            // Add leading zero if high bit is set (to keep it positive)
230 +
            if bytes[0] & 0x80 != 0 {
231 +
                bytes.insert(0, at: 0)
232 +
            }
233 +
        }
234 +
235 +
        return encode(tag: tagInteger, contents: Data(bytes))
236 +
    }
237 +
238 +
    static func integer(_ bytes: [UInt8]) -> Data {
239 +
        var adjusted = bytes
240 +
        // Remove leading zeros but keep at least one byte
241 +
        while adjusted.count > 1 && adjusted[0] == 0 && adjusted[1] & 0x80 == 0 {
242 +
            adjusted.removeFirst()
243 +
        }
244 +
        // Add leading zero if high bit is set
245 +
        if adjusted[0] & 0x80 != 0 {
246 +
            adjusted.insert(0, at: 0)
247 +
        }
248 +
        return encode(tag: tagInteger, contents: Data(adjusted))
249 +
    }
250 +
251 +
    static func bitString(_ data: Data) -> Data {
252 +
        // Prepend unused bits count (0)
253 +
        var contents = Data([0x00])
254 +
        contents.append(data)
255 +
        return encode(tag: tagBitString, contents: contents)
256 +
    }
257 +
258 +
    static func octetString(_ data: Data) -> Data {
259 +
        return encode(tag: tagOctetString, contents: data)
260 +
    }
261 +
262 +
    static func utf8String(_ string: String) -> Data {
263 +
        return encode(tag: tagUTF8String, contents: Data(string.utf8))
264 +
    }
265 +
266 +
    static func oid(_ components: [Int]) -> Data {
267 +
        guard components.count >= 2 else { return Data() }
268 +
269 +
        var bytes = [UInt8]()
270 +
271 +
        // First two components are encoded as: first * 40 + second
272 +
        bytes.append(UInt8(components[0] * 40 + components[1]))
273 +
274 +
        // Remaining components use base-128 encoding
275 +
        for i in 2..<components.count {
276 +
            var value = components[i]
277 +
            var encoded = [UInt8]()
278 +
279 +
            encoded.append(UInt8(value & 0x7F))
280 +
            value >>= 7
281 +
282 +
            while value > 0 {
283 +
                encoded.insert(UInt8((value & 0x7F) | 0x80), at: 0)
284 +
                value >>= 7
285 +
            }
286 +
287 +
            bytes.append(contentsOf: encoded)
288 +
        }
289 +
290 +
        return encode(tag: tagOID, contents: Data(bytes))
291 +
    }
292 +
293 +
    static func utcTime(_ date: Date) -> Data {
294 +
        let formatter = DateFormatter()
295 +
        formatter.dateFormat = "yyMMddHHmmss'Z'"
296 +
        formatter.timeZone = TimeZone(identifier: "UTC")
297 +
        let string = formatter.string(from: date)
298 +
        return encode(tag: tagUTCTime, contents: Data(string.utf8))
299 +
    }
300 +
301 +
    static func contextTag(_ tag: Int, explicit: Bool, contents: Data) -> Data {
302 +
        let tagByte: UInt8
303 +
        if explicit {
304 +
            tagByte = UInt8(0xA0 | (tag & 0x1F))
305 +
        } else {
306 +
            tagByte = UInt8(0x80 | (tag & 0x1F))
307 +
        }
308 +
        return encode(tag: tagByte, contents: contents)
309 +
    }
310 +
311 +
    static func rdnSequence(commonName: String) -> Data {
312 +
        // RDNSequence is a SEQUENCE of RelativeDistinguishedName (SET)
313 +
        // Each RDN contains AttributeTypeAndValue (SEQUENCE of OID and value)
314 +
315 +
        // OID for commonName: 2.5.4.3
316 +
        let cnOID = oid([2, 5, 4, 3])
317 +
        let cnValue = utf8String(commonName)
318 +
        let cnATV = sequence([cnOID, cnValue])
319 +
        let cnRDN = set([cnATV])
320 +
321 +
        return sequence([cnRDN])
322 +
    }
323 +
324 +
    private static func encode(tag: UInt8, contents: Data) -> Data {
325 +
        var result = Data([tag])
326 +
        result.append(contentsOf: encodeLength(contents.count))
327 +
        result.append(contents)
328 +
        return result
329 +
    }
330 +
331 +
    private static func encodeLength(_ length: Int) -> [UInt8] {
332 +
        if length < 128 {
333 +
            return [UInt8(length)]
334 +
        } else if length < 256 {
335 +
            return [0x81, UInt8(length)]
336 +
        } else if length < 65536 {
337 +
            return [0x82, UInt8(length >> 8), UInt8(length & 0xFF)]
338 +
        } else {
339 +
            // For larger lengths (unlikely for certificates)
340 +
            return [0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)]
341 +
        }
342 +
    }
343 +
}
Titan/Views/ClientCertificatesView.swift (added) +492 −0
1 +
//
2 +
//  ClientCertificatesView.swift
3 +
//  Titan
4 +
//
5 +
//  UI for managing client certificates
6 +
//
7 +
8 +
import SwiftUI
9 +
10 +
struct ClientCertificatesView: View {
11 +
    @Bindable var manager: ClientCertificateManager
12 +
    @Environment(\.dismiss) private var dismiss
13 +
14 +
    @State private var showCreateSheet = false
15 +
    @State private var showDeleteConfirmation = false
16 +
    @State private var certificateToDelete: ClientCertificate?
17 +
    @State private var errorMessage: String?
18 +
    @State private var showError = false
19 +
20 +
    var body: some View {
21 +
        List {
22 +
            if manager.certificates.isEmpty {
23 +
                Section {
24 +
                    VStack(spacing: 12) {
25 +
                        Image(systemName: "person.badge.key")
26 +
                            .font(.system(size: 40))
27 +
                            .foregroundStyle(.secondary)
28 +
                        Text("No Client Certificates")
29 +
                            .font(.headline)
30 +
                        Text("Client certificates are used to authenticate with Gemini capsules that require identity verification.")
31 +
                            .font(.subheadline)
32 +
                            .foregroundStyle(.secondary)
33 +
                            .multilineTextAlignment(.center)
34 +
                    }
35 +
                    .frame(maxWidth: .infinity)
36 +
                    .padding(.vertical, 20)
37 +
                }
38 +
            } else {
39 +
                Section {
40 +
                    ForEach(manager.certificates) { certificate in
41 +
                        NavigationLink {
42 +
                            ClientCertificateDetailView(
43 +
                                certificate: certificate,
44 +
                                manager: manager
45 +
                            )
46 +
                        } label: {
47 +
                            CertificateRow(certificate: certificate)
48 +
                        }
49 +
                    }
50 +
                    .onDelete(perform: confirmDelete)
51 +
                } header: {
52 +
                    Text("Certificates")
53 +
                } footer: {
54 +
                    Text("Swipe left to delete a certificate.")
55 +
                }
56 +
            }
57 +
58 +
            Section {
59 +
                Button {
60 +
                    showCreateSheet = true
61 +
                } label: {
62 +
                    Label("Create New Certificate", systemImage: "plus.circle")
63 +
                }
64 +
            }
65 +
        }
66 +
        .navigationTitle("Client Certificates")
67 +
        .navigationBarTitleDisplayMode(.inline)
68 +
        .sheet(isPresented: $showCreateSheet) {
69 +
            CreateClientCertificateView(manager: manager)
70 +
        }
71 +
        .alert("Error", isPresented: $showError) {
72 +
            Button("OK", role: .cancel) {}
73 +
        } message: {
74 +
            Text(errorMessage ?? "An unknown error occurred")
75 +
        }
76 +
        .alert("Delete Certificate?", isPresented: $showDeleteConfirmation) {
77 +
            Button("Cancel", role: .cancel) {
78 +
                certificateToDelete = nil
79 +
            }
80 +
            Button("Delete", role: .destructive) {
81 +
                if let cert = certificateToDelete {
82 +
                    deleteCertificate(cert)
83 +
                }
84 +
            }
85 +
        } message: {
86 +
            if let cert = certificateToDelete {
87 +
                Text("This will delete \"\(cert.name)\" and remove all its site associations. This cannot be undone.")
88 +
            }
89 +
        }
90 +
    }
91 +
92 +
    private func confirmDelete(at offsets: IndexSet) {
93 +
        if let index = offsets.first {
94 +
            certificateToDelete = manager.certificates[index]
95 +
            showDeleteConfirmation = true
96 +
        }
97 +
    }
98 +
99 +
    private func deleteCertificate(_ certificate: ClientCertificate) {
100 +
        do {
101 +
            try manager.deleteCertificate(id: certificate.id)
102 +
        } catch {
103 +
            errorMessage = error.localizedDescription
104 +
            showError = true
105 +
        }
106 +
        certificateToDelete = nil
107 +
    }
108 +
}
109 +
110 +
struct CertificateRow: View {
111 +
    let certificate: ClientCertificate
112 +
113 +
    var body: some View {
114 +
        VStack(alignment: .leading, spacing: 4) {
115 +
            HStack {
116 +
                Text(certificate.name)
117 +
                    .font(.headline)
118 +
                if certificate.isExpired {
119 +
                    Text("Expired")
120 +
                        .font(.caption)
121 +
                        .foregroundStyle(.white)
122 +
                        .padding(.horizontal, 6)
123 +
                        .padding(.vertical, 2)
124 +
                        .background(.red)
125 +
                        .cornerRadius(4)
126 +
                } else if certificate.daysUntilExpiry < 30 {
127 +
                    Text("\(certificate.daysUntilExpiry)d")
128 +
                        .font(.caption)
129 +
                        .foregroundStyle(.white)
130 +
                        .padding(.horizontal, 6)
131 +
                        .padding(.vertical, 2)
132 +
                        .background(.orange)
133 +
                        .cornerRadius(4)
134 +
                }
135 +
            }
136 +
137 +
            Text(truncatedFingerprint(certificate.fingerprint))
138 +
                .font(.caption)
139 +
                .foregroundStyle(.secondary)
140 +
                .fontDesign(.monospaced)
141 +
        }
142 +
        .padding(.vertical, 2)
143 +
    }
144 +
145 +
    private func truncatedFingerprint(_ fingerprint: String) -> String {
146 +
        let parts = fingerprint.split(separator: ":")
147 +
        if parts.count > 8 {
148 +
            return parts.prefix(4).joined(separator: ":") + "..." + parts.suffix(4).joined(separator: ":")
149 +
        }
150 +
        return fingerprint
151 +
    }
152 +
}
153 +
154 +
struct ClientCertificateDetailView: View {
155 +
    let certificate: ClientCertificate
156 +
    @Bindable var manager: ClientCertificateManager
157 +
    @Environment(\.dismiss) private var dismiss
158 +
159 +
    @State private var showDeleteConfirmation = false
160 +
    @State private var errorMessage: String?
161 +
    @State private var showError = false
162 +
163 +
    var associations: [CertificateHostAssociation] {
164 +
        manager.getAssociationsForCertificate(id: certificate.id)
165 +
    }
166 +
167 +
    var body: some View {
168 +
        List {
169 +
            Section {
170 +
                LabeledContent("Name", value: certificate.name)
171 +
                LabeledContent("Created", value: certificate.createdAt.formatted(date: .abbreviated, time: .shortened))
172 +
                LabeledContent("Expires", value: certificate.expiresAt.formatted(date: .abbreviated, time: .shortened))
173 +
174 +
                if certificate.isExpired {
175 +
                    HStack {
176 +
                        Image(systemName: "exclamationmark.triangle.fill")
177 +
                            .foregroundStyle(.red)
178 +
                        Text("This certificate has expired")
179 +
                            .foregroundStyle(.red)
180 +
                    }
181 +
                } else if certificate.daysUntilExpiry < 30 {
182 +
                    HStack {
183 +
                        Image(systemName: "exclamationmark.triangle.fill")
184 +
                            .foregroundStyle(.orange)
185 +
                        Text("Expires in \(certificate.daysUntilExpiry) days")
186 +
                            .foregroundStyle(.orange)
187 +
                    }
188 +
                }
189 +
            } header: {
190 +
                Text("Certificate Info")
191 +
            }
192 +
193 +
            Section {
194 +
                Text(certificate.fingerprint)
195 +
                    .font(.system(.caption, design: .monospaced))
196 +
                    .foregroundStyle(.secondary)
197 +
                    .textSelection(.enabled)
198 +
            } header: {
199 +
                Text("SHA-256 Fingerprint")
200 +
            } footer: {
201 +
                Text("This fingerprint uniquely identifies your certificate.")
202 +
            }
203 +
204 +
            Section {
205 +
                if associations.isEmpty {
206 +
                    Text("No sites are using this certificate")
207 +
                        .foregroundStyle(.secondary)
208 +
                } else {
209 +
                    ForEach(associations) { assoc in
210 +
                        VStack(alignment: .leading, spacing: 2) {
211 +
                            Text("\(assoc.host):\(assoc.port)")
212 +
                                .font(.body)
213 +
                            Text(assoc.pathPrefix)
214 +
                                .font(.caption)
215 +
                                .foregroundStyle(.secondary)
216 +
                        }
217 +
                    }
218 +
                    .onDelete(perform: deleteAssociation)
219 +
                }
220 +
            } header: {
221 +
                Text("Associated Sites")
222 +
            } footer: {
223 +
                if !associations.isEmpty {
224 +
                    Text("Swipe left to remove a site association.")
225 +
                }
226 +
            }
227 +
228 +
            Section {
229 +
                Button(role: .destructive) {
230 +
                    showDeleteConfirmation = true
231 +
                } label: {
232 +
                    Label("Delete Certificate", systemImage: "trash")
233 +
                }
234 +
            }
235 +
        }
236 +
        .navigationTitle(certificate.name)
237 +
        .navigationBarTitleDisplayMode(.inline)
238 +
        .alert("Delete Certificate?", isPresented: $showDeleteConfirmation) {
239 +
            Button("Cancel", role: .cancel) {}
240 +
            Button("Delete", role: .destructive) {
241 +
                deleteCertificate()
242 +
            }
243 +
        } message: {
244 +
            Text("This will delete \"\(certificate.name)\" and remove all its site associations. This cannot be undone.")
245 +
        }
246 +
        .alert("Error", isPresented: $showError) {
247 +
            Button("OK", role: .cancel) {}
248 +
        } message: {
249 +
            Text(errorMessage ?? "An unknown error occurred")
250 +
        }
251 +
    }
252 +
253 +
    private func deleteAssociation(at offsets: IndexSet) {
254 +
        for index in offsets {
255 +
            let assoc = associations[index]
256 +
            manager.removeAssociation(host: assoc.host, port: assoc.port, pathPrefix: assoc.pathPrefix)
257 +
        }
258 +
    }
259 +
260 +
    private func deleteCertificate() {
261 +
        do {
262 +
            try manager.deleteCertificate(id: certificate.id)
263 +
            dismiss()
264 +
        } catch {
265 +
            errorMessage = error.localizedDescription
266 +
            showError = true
267 +
        }
268 +
    }
269 +
}
270 +
271 +
struct CreateClientCertificateView: View {
272 +
    @Bindable var manager: ClientCertificateManager
273 +
    @Environment(\.dismiss) private var dismiss
274 +
275 +
    @State private var name = ""
276 +
    @State private var isCreating = false
277 +
    @State private var errorMessage: String?
278 +
    @State private var showError = false
279 +
280 +
    var isValid: Bool {
281 +
        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
282 +
    }
283 +
284 +
    var body: some View {
285 +
        NavigationStack {
286 +
            Form {
287 +
                Section {
288 +
                    TextField("Certificate Name", text: $name)
289 +
                        .autocapitalization(.words)
290 +
                        .disabled(isCreating)
291 +
                } header: {
292 +
                    Text("Name")
293 +
                } footer: {
294 +
                    Text("Choose a name to identify this certificate (e.g., \"Personal\", \"BBS Account\").")
295 +
                }
296 +
297 +
                Section {
298 +
                    VStack(alignment: .leading, spacing: 8) {
299 +
                        Label("EC P-256 Key", systemImage: "key")
300 +
                        Label("SHA-256 Signatures", systemImage: "signature")
301 +
                        Label("Valid for 1 year", systemImage: "calendar")
302 +
                        Label("Stored in Keychain", systemImage: "lock.shield")
303 +
                    }
304 +
                    .font(.subheadline)
305 +
                    .foregroundStyle(.secondary)
306 +
                } header: {
307 +
                    Text("Certificate Details")
308 +
                }
309 +
            }
310 +
            .navigationTitle("New Certificate")
311 +
            .navigationBarTitleDisplayMode(.inline)
312 +
            .toolbar {
313 +
                ToolbarItem(placement: .cancellationAction) {
314 +
                    Button("Cancel") {
315 +
                        dismiss()
316 +
                    }
317 +
                    .disabled(isCreating)
318 +
                }
319 +
                ToolbarItem(placement: .confirmationAction) {
320 +
                    if isCreating {
321 +
                        ProgressView()
322 +
                    } else {
323 +
                        Button("Create") {
324 +
                            createCertificate()
325 +
                        }
326 +
                        .disabled(!isValid)
327 +
                    }
328 +
                }
329 +
            }
330 +
            .alert("Error", isPresented: $showError) {
331 +
                Button("OK", role: .cancel) {}
332 +
            } message: {
333 +
                Text(errorMessage ?? "An unknown error occurred")
334 +
            }
335 +
        }
336 +
    }
337 +
338 +
    private func createCertificate() {
339 +
        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
340 +
        guard !trimmedName.isEmpty else { return }
341 +
342 +
        isCreating = true
343 +
344 +
        Task {
345 +
            do {
346 +
                _ = try manager.createCertificate(name: trimmedName)
347 +
                await MainActor.run {
348 +
                    dismiss()
349 +
                }
350 +
            } catch {
351 +
                await MainActor.run {
352 +
                    errorMessage = error.localizedDescription
353 +
                    showError = true
354 +
                    isCreating = false
355 +
                }
356 +
            }
357 +
        }
358 +
    }
359 +
}
360 +
361 +
// MARK: - Certificate Selection View (for status 60 prompts)
362 +
363 +
struct CertificateSelectionView: View {
364 +
    @Bindable var manager: ClientCertificateManager
365 +
    let url: String
366 +
    let serverMessage: String
367 +
    let onSelect: (UUID) -> Void
368 +
    let onCancel: () -> Void
369 +
370 +
    @State private var showCreateSheet = false
371 +
    @State private var selectedCertificateId: UUID?
372 +
373 +
    var body: some View {
374 +
        NavigationStack {
375 +
            VStack(spacing: 0) {
376 +
                // Header info
377 +
                VStack(spacing: 8) {
378 +
                    Image(systemName: "lock.shield")
379 +
                        .font(.system(size: 40))
380 +
                        .foregroundStyle(.purple)
381 +
382 +
                    Text("Certificate Required")
383 +
                        .font(.headline)
384 +
385 +
                    if !serverMessage.isEmpty {
386 +
                        Text(serverMessage)
387 +
                            .font(.subheadline)
388 +
                            .foregroundStyle(.secondary)
389 +
                            .multilineTextAlignment(.center)
390 +
                    }
391 +
392 +
                    if let urlHost = URL(string: url)?.host {
393 +
                        Text(urlHost)
394 +
                            .font(.caption)
395 +
                            .foregroundStyle(.secondary)
396 +
                            .padding(.horizontal, 12)
397 +
                            .padding(.vertical, 4)
398 +
                            .background(Color.secondary.opacity(0.15))
399 +
                            .cornerRadius(8)
400 +
                    }
401 +
                }
402 +
                .padding()
403 +
404 +
                Divider()
405 +
406 +
                // Certificate list
407 +
                List {
408 +
                    if manager.certificates.isEmpty {
409 +
                        Section {
410 +
                            Text("No certificates available. Create one to continue.")
411 +
                                .foregroundStyle(.secondary)
412 +
                        }
413 +
                    } else {
414 +
                        Section {
415 +
                            ForEach(manager.certificates) { cert in
416 +
                                Button {
417 +
                                    selectedCertificateId = cert.id
418 +
                                } label: {
419 +
                                    HStack {
420 +
                                        VStack(alignment: .leading, spacing: 2) {
421 +
                                            Text(cert.name)
422 +
                                                .foregroundStyle(.primary)
423 +
                                            Text(truncatedFingerprint(cert.fingerprint))
424 +
                                                .font(.caption)
425 +
                                                .foregroundStyle(.secondary)
426 +
                                                .fontDesign(.monospaced)
427 +
                                        }
428 +
                                        Spacer()
429 +
                                        if selectedCertificateId == cert.id {
430 +
                                            Image(systemName: "checkmark.circle.fill")
431 +
                                                .foregroundStyle(.blue)
432 +
                                        }
433 +
                                    }
434 +
                                }
435 +
                                .disabled(cert.isExpired)
436 +
                                .opacity(cert.isExpired ? 0.5 : 1.0)
437 +
                            }
438 +
                        } header: {
439 +
                            Text("Select Certificate")
440 +
                        }
441 +
                    }
442 +
443 +
                    Section {
444 +
                        Button {
445 +
                            showCreateSheet = true
446 +
                        } label: {
447 +
                            Label("Create New Certificate", systemImage: "plus.circle")
448 +
                        }
449 +
                    }
450 +
                }
451 +
                .listStyle(.insetGrouped)
452 +
            }
453 +
            .navigationBarTitleDisplayMode(.inline)
454 +
            .toolbar {
455 +
                ToolbarItem(placement: .cancellationAction) {
456 +
                    Button("Cancel") {
457 +
                        onCancel()
458 +
                    }
459 +
                }
460 +
                ToolbarItem(placement: .confirmationAction) {
461 +
                    Button("Use Certificate") {
462 +
                        if let id = selectedCertificateId {
463 +
                            onSelect(id)
464 +
                        }
465 +
                    }
466 +
                    .disabled(selectedCertificateId == nil)
467 +
                }
468 +
            }
469 +
            .sheet(isPresented: $showCreateSheet) {
470 +
                CreateClientCertificateView(manager: manager)
471 +
            }
472 +
        }
473 +
    }
474 +
475 +
    private func truncatedFingerprint(_ fingerprint: String) -> String {
476 +
        let parts = fingerprint.split(separator: ":")
477 +
        if parts.count > 8 {
478 +
            return parts.prefix(4).joined(separator: ":") + "..." + parts.suffix(4).joined(separator: ":")
479 +
        }
480 +
        return fingerprint
481 +
    }
482 +
}
483 +
484 +
#Preview("Certificate List") {
485 +
    NavigationStack {
486 +
        ClientCertificatesView(manager: ClientCertificateManager())
487 +
    }
488 +
}
489 +
490 +
#Preview("Create Certificate") {
491 +
    CreateClientCertificateView(manager: ClientCertificateManager())
492 +
}
Titan/Views/ContentView.swift +52 −9
51 51
    // Error state
52 52
    @State private var currentError: GeminiErrorType?
53 53
54 -
    // Certificate management
54 +
    // Server certificate management (TOFU)
55 55
    @State private var certificateManager = CertificateManager()
56 56
    @State private var showCertificateMismatchAlert = false
57 57
    @State private var pendingCertificateChange: (hostname: String, storedFingerprint: String, newFingerprint: String, commonName: String)?
58 -
    @State private var pendingCertificateURL: String?
58 +
    @State private var pendingServerCertificateURL: String?
59 +
60 +
    // Client certificate management
61 +
    @State private var clientCertificateManager = ClientCertificateManager()
62 +
    @State private var showClientCertificatePrompt = false
63 +
    @State private var pendingClientCertificateURL: String?
64 +
    @State private var pendingClientCertificateMeta: String?
59 65
60 66
    private let maxRedirects = 5
61 67
163 169
            }
164 170
        }
165 171
        .sheet(isPresented: $showSettings) {
166 -
            SettingsView()
172 +
            SettingsView(clientCertificateManager: clientCertificateManager)
173 +
        }
174 +
        .sheet(isPresented: $showClientCertificatePrompt) {
175 +
            CertificateSelectionView(
176 +
                manager: clientCertificateManager,
177 +
                url: pendingClientCertificateURL ?? "",
178 +
                serverMessage: pendingClientCertificateMeta ?? "",
179 +
                onSelect: { certificateId in
180 +
                    // Associate certificate with URL and retry
181 +
                    if let urlString = pendingClientCertificateURL,
182 +
                       let url = URL(string: urlString) {
183 +
                        clientCertificateManager.associateCertificate(certificateId: certificateId, with: url)
184 +
                        showClientCertificatePrompt = false
185 +
                        pendingClientCertificateURL = nil
186 +
                        pendingClientCertificateMeta = nil
187 +
                        currentError = nil
188 +
                        navigateTo(urlString)
189 +
                    }
190 +
                },
191 +
                onCancel: {
192 +
                    showClientCertificatePrompt = false
193 +
                    // Keep the error displayed
194 +
                }
195 +
            )
167 196
        }
168 197
        .sheet(isPresented: $showHistory) {
169 198
            HistoryListView(historyManager: historyManager) { item in
186 215
        .alert("Certificate Changed", isPresented: $showCertificateMismatchAlert) {
187 216
            Button("Reject", role: .cancel) {
188 217
                pendingCertificateChange = nil
189 -
                pendingCertificateURL = nil
218 +
                pendingServerCertificateURL = nil
190 219
            }
191 220
            Button("Accept New Certificate") {
192 221
                if let change = pendingCertificateChange {
196 225
                        commonName: change.commonName
197 226
                    )
198 227
                    // Retry navigation with the updated certificate
199 -
                    if let url = pendingCertificateURL {
228 +
                    if let url = pendingServerCertificateURL {
200 229
                        navigateTo(url)
201 230
                    }
202 231
                }
203 232
                pendingCertificateChange = nil
204 -
                pendingCertificateURL = nil
233 +
                pendingServerCertificateURL = nil
205 234
            }
206 235
        } message: {
207 236
            if let change = pendingCertificateChange {
416 445
                        addToNavigationHistory(url: finalURL)
417 446
                    }
418 447
                case .clientCertificate:
419 -
                    currentError = .clientCertificate(code: response.statusCode, meta: response.meta)
448 +
                    if response.statusCode == 60 {
449 +
                        // Certificate required - show selection prompt
450 +
                        pendingClientCertificateURL = finalURL
451 +
                        pendingClientCertificateMeta = response.meta
452 +
                        showClientCertificatePrompt = true
453 +
                        currentError = .clientCertificate(code: response.statusCode, meta: response.meta)
454 +
                    } else {
455 +
                        // 61 (not authorized) or 62 (not valid) - show error
456 +
                        currentError = .clientCertificate(code: response.statusCode, meta: response.meta)
457 +
                    }
420 458
                    if addToHistory {
421 459
                        addToNavigationHistory(url: finalURL)
422 460
                    }
438 476
                    return
439 477
                case .certificateMismatch(let hostname, let storedFingerprint, let newFingerprint, let commonName):
440 478
                    pendingCertificateChange = (hostname, storedFingerprint, newFingerprint, commonName)
441 -
                    pendingCertificateURL = urlText
479 +
                    pendingServerCertificateURL = urlText
442 480
                    showCertificateMismatchAlert = true
443 481
                    isLoading = false
444 482
                    return
470 508
471 509
        let client = GeminiClient(certificateManager: certificateManager)
472 510
        let port = url.port ?? 1965
511 +
512 +
        // Look up client certificate for this URL
513 +
        let clientIdentity = clientCertificateManager.findIdentity(for: url)
514 +
473 515
        let response = try await client.connect(
474 516
            hostname: host,
475 517
            port: port,
476 -
            urlString: urlString
518 +
            urlString: urlString,
519 +
            clientIdentity: clientIdentity
477 520
        )
478 521
479 522
        // Check for cancellation after fetch
Titan/Views/ErrorPageView.swift +15 −2
181 181
            switch code {
182 182
            case 60:
183 183
                return [
184 -
                    "Client certificates are not yet supported",
185 -
                    "Some Gemini sites require authentication"
184 +
                    "Select or create a certificate to authenticate",
185 +
                    "Certificates are stored securely in your Keychain",
186 +
                    "You can manage certificates in Settings"
187 +
                ]
188 +
            case 61:
189 +
                return [
190 +
                    "Try using a different certificate",
191 +
                    "The server may require a specific identity",
192 +
                    "Contact the site administrator"
193 +
                ]
194 +
            case 62:
195 +
                return [
196 +
                    "Your certificate may have expired",
197 +
                    "Create a new certificate in Settings",
198 +
                    "Check certificate details in Settings"
186 199
                ]
187 200
            default:
188 201
                return [
Titan/Views/SettingsView.swift +19 −1
8 8
struct SettingsView: View {
9 9
    @EnvironmentObject private var themeSettings: ThemeSettings
10 10
    @Environment(\.dismiss) private var dismiss
11 +
    var clientCertificateManager: ClientCertificateManager
11 12
12 13
    @State private var homePageText: String = ""
13 14
    @State private var searchEngineText: String = ""
80 81
                } header: {
81 82
                    Text("Dark Mode Colors")
82 83
                }
84 +
85 +
                Section {
86 +
                    NavigationLink {
87 +
                        ClientCertificatesView(manager: clientCertificateManager)
88 +
                    } label: {
89 +
                        HStack {
90 +
                            Label("Client Certificates", systemImage: "person.badge.key")
91 +
                            Spacer()
92 +
                            Text("\(clientCertificateManager.certificates.count)")
93 +
                                .foregroundStyle(.secondary)
94 +
                        }
95 +
                    }
96 +
                } header: {
97 +
                    Text("Security")
98 +
                } footer: {
99 +
                    Text("Manage certificates for authenticating with Gemini capsules.")
100 +
                }
83 101
            }
84 102
            .navigationTitle("Settings")
85 103
            .navigationBarTitleDisplayMode(.inline)
120 138
}
121 139
122 140
#Preview {
123 -
    SettingsView()
141 +
    SettingsView(clientCertificateManager: ClientCertificateManager())
124 142
        .environmentObject(ThemeSettings())
125 143
}