Как работать с самоподписанными SSL-сертификатами в iOS?

Ответ

Работа с самоподписанными сертификатами требует настройки URLSession для обработки SSL-рукопожатия в обход стандартной проверки доверенных центров сертификации (CA).

Основной подход — использование делегата URLSession:

  1. Реализовать метод urlSession(_:didReceive:completionHandler:).
  2. Принять (useCredential) или отклонить (cancelAuthenticationChallenge) сертификат на основе кастомной логики.

Пример: Принятие конкретного самоподписанного сертификата из бандла

class SSLPinningDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // 1. Загружаем локальный сертификат
        guard let certPath = Bundle.main.path(forResource: "my_server", ofType: "der"),
              let localCertData = try? Data(contentsOf: URL(fileURLWithPath: certPath)) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // 2. Извлекаем удаленный сертификат
        let remoteCert = SecTrustGetCertificateAtIndex(serverTrust, 0)
        let remoteCertData = SecCertificateCopyData(remoteCert!) as Data

        // 3. Сравниваем данные сертификатов (пиннинг)
        if localCertData == remoteCertData {
            let credential = URLCredential(trust: serverTrust)
            completionHandler(.useCredential, credential)
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

// Использование
let delegate = SSLPinningDelegate()
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)

Ключевые моменты и предупреждения:

  • ⚠️ Безопасность: Такой подход ослабляет защиту и должен использоваться только для тестирования (dev/staging) или внутри защищенных корпоративных сетей.
  • Пиннинг (Pinning): Для продакшена рекомендуется пиннинг публичных ключей или сертификатов от доверенных CA, а не использование самоподписанных.
  • Info.plist: Для iOS 9+ может потребоваться отключение ATS (NSAppTransportSecurity) для доменов с такими сертификатами, что также является плохой практикой.

Ответ 18+ 🔞

Да ты посмотри, какая история! Сидишь ты такой, пишешь своё приложение, всё вроде красиво, а тут — бац! — сервер с самоподписанным сертификатом. И твой URLSession смотрит на это дело и такой: «Ну нахуй, я с таким не разговариваю». И всё, пиши пропало, запросы не летят.

А выход-то есть, да ещё какой! Надо просто научить свою сессию не быть такой занудой и доверять этому конкретному, пусть и самоподписанному, листочку бумаги. Делается это через делегата.

Слушай, как это выглядит в коде, сейчас обалдеешь. Берёшь и создаёшь класс, который будет этим всем безобразием руководить.

class SSLPinningDelegate: NSObject, URLSessionDelegate {
    func urlSession(_ session: URLSession,
                    didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

        // Сначала проверяем, а та ли это проблема? Может, сервер просто пароль просит?
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            // Не наша тема, пусть система сама разбирается
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // Ага, наша! Теперь достаём наш локальный сертификат, который мы как зеницу ока храним.
        guard let certPath = Bundle.main.path(forResource: "my_server", ofType: "der"),
              let localCertData = try? Data(contentsOf: URL(fileURLWithPath: certPath)) else {
            // Не нашли? Ну всё, пиздец, обрываем связь.
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Теперь вытаскиваем сертификат, который нам прислал сервер.
        let remoteCert = SecTrustGetCertificateAtIndex(serverTrust, 0)
        let remoteCertData = SecCertificateCopyData(remoteCert!) as Data

        // И наконец-то, момент истины! Сравниваем два куска данных.
        if localCertData == remoteCertData {
            // Ура, они одинаковые! Это наш парень! Доверяем ему.
            let credential = URLCredential(trust: serverTrust)
            completionHandler(.useCredential, credential)
        } else {
            // Ой, а это кто такой? Не наш! Нахуй такого!
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }
}

Использовать — проще пареной репы:

let delegate = SSLPinningDelegate()
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)

Всё, теперь твоя сессия будет общаться только с тем сервером, чей сертификат ты ей вручил, как пропуск.

НО! Э, погоди-ка, не спеши радоваться! Тут есть нюансы, ёпта, овердохуища!

  • ⚠️ Безопасность — на нуле! Ты же понимаешь, что ты сейчас отключил встроенного охранника? Это как снять замок с двери и повесить записку «Входите, свои». Делай так ТОЛЬКО для тестов (dev/staging) или внутри своей закрытой сети, где все свои. Для прода — это пиздец какой риск.
  • Правильный пиннинг — это не так. Для нормального, боевого приложения нужно цепляться не к самоподписанному сертификату, а к публичному ключу сертификата от нормального, доверенного центра сертификации (CA). Вот это будет мощно.
  • Info.plist — тоже придётся трогать. На iOS 9 и выше эта система ATS такая бдительная, что может заблокировать соединение ещё до твоего делегата. Придётся в Info.plist для этого домена прописывать NSExceptionAllowsInsecureHTTPLoads. И это тоже, блядь, красный флаг для ревьюеров и вообще для совести.