You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionUtilitiesKit/Networking/HTTP.swift

229 lines
12 KiB
Swift

import Foundation
import PromiseKit
public enum HTTP {
private static let seedNodeURLSession = URLSession(configuration: .ephemeral, delegate: seedNodeURLSessionDelegate, delegateQueue: nil)
private static let seedNodeURLSessionDelegate = SeedNodeURLSessionDelegateImplementation()
private static let snodeURLSession = URLSession(configuration: .ephemeral, delegate: snodeURLSessionDelegate, delegateQueue: nil)
private static let snodeURLSessionDelegate = SnodeURLSessionDelegateImplementation()
// MARK: Certificates
3 years ago
private static let storageSeed1Cert: SecCertificate = {
let path = Bundle.main.path(forResource: "seed1-10y", ofType: "der")!
3 years ago
let data = try! Data(contentsOf: URL(fileURLWithPath: path))
return SecCertificateCreateWithData(nil, data as CFData)!
}()
private static let storageSeed2Cert: SecCertificate = {
let path = Bundle.main.path(forResource: "seed2-10y", ofType: "der")!
3 years ago
let data = try! Data(contentsOf: URL(fileURLWithPath: path))
return SecCertificateCreateWithData(nil, data as CFData)!
}()
private static let storageSeed3Cert: SecCertificate = {
let path = Bundle.main.path(forResource: "seed3-10y", ofType: "der")!
3 years ago
let data = try! Data(contentsOf: URL(fileURLWithPath: path))
return SecCertificateCreateWithData(nil, data as CFData)!
}()
// MARK: Settings
public static let timeout: TimeInterval = 10
// MARK: Seed Node URL Session Delegate Implementation
private final class SeedNodeURLSessionDelegateImplementation : NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
3 years ago
guard let trust = challenge.protectionSpace.serverTrust else {
return completionHandler(.cancelAuthenticationChallenge, nil)
}
// Mark the seed node certificates as trusted
let certificates = [ storageSeed1Cert, storageSeed2Cert, storageSeed3Cert ]
3 years ago
guard SecTrustSetAnchorCertificates(trust, certificates as CFArray) == errSecSuccess else {
SNLog("Failed to set seed node certificates.")
3 years ago
return completionHandler(.cancelAuthenticationChallenge, nil)
}
// Check that the presented certificate is one of the seed node certificates
var error: CFError?
guard SecTrustEvaluateWithError(trust, &error) else {
// Extract the result for further processing (since we are defaulting to `invalid` we
// don't care if extracting the result type fails)
var result: SecTrustResultType = .invalid
_ = SecTrustGetTrustResult(trust, &result)
switch result {
case .proceed, .unspecified:
/// Unspecified indicates that evaluation reached an (implicitly trusted) anchor certificate without any evaluation
/// failures, but never encountered any explicitly stated user-trust preference. This is the most common return
/// value. The Keychain Access utility refers to this value as the "Use System Policy," which is the default user setting.
return completionHandler(.useCredential, URLCredential(trust: trust))
case .recoverableTrustFailure:
/// A recoverable failure generally suggests that the certificate was mostly valid but something minor didn't line up,
/// iOS has a specific rule which rejects certificates which have a lifetime over 825 days which we don't really care
/// about so if we end up with a single issue which is `OtherTrustValidityPeriod` then we can just allow
/// the request to continue
guard
let validationResult: [String: Any] = SecTrustCopyResult(trust) as? [String: Any],
let details: [String: Any] = (validationResult["TrustResultDetails"] as? [[String: Any]])?
.reduce(into: [:], { result, next in next.forEach { result[$0.key] = $0.value } }),
let otherTrustValidityPeriod: Int = details["OtherTrustValidityPeriod"] as? Int,
details.count == 1,
otherTrustValidityPeriod == 0,
let exceptions: CFData = SecTrustCopyExceptions(trust),
SecTrustSetExceptions(trust, exceptions)
else {
let reason: String = {
guard
let validationResult: [String: Any] = SecTrustCopyResult(trust) as? [String: Any],
let details: [String: Any] = (validationResult["TrustResultDetails"] as? [[String: Any]])?
.reduce(into: [:], { result, next in next.forEach { result[$0.key] = $0.value } })
else { return "Unknown" }
return "\(details)"
}()
SNLog("Failed to handle a recoverable seed certificate trust failure: \(reason)")
return completionHandler(.cancelAuthenticationChallenge, nil)
}
/// Now that the `trust` has been updated with the exceptions it can ignore we need to try to re-evaluate it
/// to ensure it is now seen as valid
var error2: CFError? = nil
guard SecTrustEvaluateWithError(trust, &error2) else {
SNLog("Seed certificate reevaluation failed due to error: \(String(describing: error2))")
return completionHandler(.cancelAuthenticationChallenge, nil)
}
/// If the reevaluation succeeded then try to use the credential
///
/// **Note:** It is still possible for the OS to reject the request (which seems to be happening with an expired
/// certificate) but it _does_ seem to work fine with the 10 year certificate
return completionHandler(.useCredential, URLCredential(trust: trust))
default: return completionHandler(.cancelAuthenticationChallenge, nil)
}
}
return completionHandler(.useCredential, URLCredential(trust: trust))
}
}
// MARK: Snode URL Session Delegate Implementation
private final class SnodeURLSessionDelegateImplementation : NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Snode to snode communication uses self-signed certificates but clients can safely ignore this
completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
}
// MARK: - Verb
public enum Verb: String, Codable {
case get = "GET"
case put = "PUT"
case post = "POST"
case delete = "DELETE"
}
// MARK: - Error
public enum Error: LocalizedError, Equatable {
case generic
case invalidURL
case invalidJSON
case parsingFailed
case invalidResponse
case maxFileSizeExceeded
case httpRequestFailed(statusCode: UInt, data: Data?)
case timeout
public var errorDescription: String? {
switch self {
case .generic: return "An error occurred."
case .invalidURL: return "Invalid URL."
case .invalidJSON: return "Invalid JSON."
case .parsingFailed, .invalidResponse: return "Invalid response."
case .maxFileSizeExceeded: return "Maximum file size exceeded."
case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)."
case .timeout: return "The request timed out."
}
}
}
// MARK: - Main
public static func execute(_ verb: Verb, _ url: String, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise<Data> {
return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
}
public static func execute(_ verb: Verb, _ url: String, parameters: JSON?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise<Data> {
if let parameters = parameters {
do {
guard JSONSerialization.isValidJSONObject(parameters) else { return Promise(error: Error.invalidJSON) }
let body = try JSONSerialization.data(withJSONObject: parameters, options: [ .fragmentsAllowed ])
return execute(verb, url, body: body, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
}
catch (let error) {
return Promise(error: error)
}
}
else {
return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession)
}
}
public static func execute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise<Data> {
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = verb.rawValue
request.httpBody = body
request.timeoutInterval = timeout
request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent")
request.setValue("WhatsApp", forHTTPHeaderField: "User-Agent") // Set a fake value
request.setValue("en-us", forHTTPHeaderField: "Accept-Language") // Set a fake value
let (promise, seal) = Promise<Data>.pending()
let urlSession = useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession
let task = urlSession.dataTask(with: request) { data, response, error in
guard let data = data, let response = response as? HTTPURLResponse else {
if let error = error {
SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).")
} else {
SNLog("\(verb.rawValue) request to \(url) failed.")
}
// Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:)
switch (error as? NSError)?.code {
case NSURLErrorTimedOut: return seal.reject(Error.timeout)
default: return seal.reject(Error.httpRequestFailed(statusCode: 0, data: nil))
}
}
if let error = error {
SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).")
// Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:)
return seal.reject(Error.httpRequestFailed(statusCode: 0, data: data))
}
let statusCode = UInt(response.statusCode)
guard 200...299 ~= statusCode else {
var json: JSON? = nil
if let processedJson: JSON = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON {
json = processedJson
}
else if let result: String = String(data: data, encoding: .utf8) {
json = [ "result": result ]
}
let jsonDescription: String = (json?.prettifiedDescription ?? "no debugging info provided")
SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).")
return seal.reject(Error.httpRequestFailed(statusCode: statusCode, data: data))
}
seal.fulfill(data)
}
task.resume()
return promise
}
}