feat: added client certificates and management
e3835bdb
7 file(s) · +1344 −13
| 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 | + | } |
| 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() |
|
| 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 | + | } |
| 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 | + | } |
| 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 |
|
| 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 [ |
| 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 | } |
|