mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Implement onion request encryption
This commit is contained in:
parent
fd037c2a88
commit
1b24637f37
|
@ -53,11 +53,11 @@ public extension LokiAPI {
|
|||
let rawResponse = intermediate.responseObject
|
||||
guard let json = rawResponse as? JSON, let intermediate = json["result"] as? JSON, let rawTargets = intermediate["service_node_states"] as? [JSON] else { throw LokiAPIError.randomSnodePoolUpdatingFailed }
|
||||
randomSnodePool = try Set(rawTargets.flatMap { rawTarget in
|
||||
guard let address = rawTarget["public_ip"] as? String, let port = rawTarget["storage_port"] as? Int, let idKey = rawTarget["pubkey_ed25519"] as? String, let encryptionKey = rawTarget["pubkey_x25519"] as? String, address != "0.0.0.0" else {
|
||||
guard let address = rawTarget["public_ip"] as? String, let port = rawTarget["storage_port"] as? Int, let ed25519PublicKey = rawTarget["pubkey_ed25519"] as? String, let x25519PublicKey = rawTarget["pubkey_x25519"] as? String, address != "0.0.0.0" else {
|
||||
print("[Loki] Failed to parse target from: \(rawTarget).")
|
||||
return nil
|
||||
}
|
||||
return LokiAPITarget(address: "https://\(address)", port: UInt16(port), publicKeySet: LokiAPITarget.KeySet(idKey: idKey, encryptionKey: encryptionKey))
|
||||
return LokiAPITarget(address: "https://\(address)", port: UInt16(port), publicKeySet: LokiAPITarget.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey))
|
||||
})
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
return randomSnodePool.randomElement()!
|
||||
|
@ -132,11 +132,11 @@ public extension LokiAPI {
|
|||
return []
|
||||
}
|
||||
return rawTargets.flatMap { rawTarget in
|
||||
guard let address = rawTarget["ip"] as? String, let portAsString = rawTarget["port"] as? String, let port = UInt16(portAsString), let idKey = rawTarget["pubkey_ed25519"] as? String, let encryptionKey = rawTarget["pubkey_x25519"] as? String, address != "0.0.0.0" else {
|
||||
guard let address = rawTarget["ip"] as? String, let portAsString = rawTarget["port"] as? String, let port = UInt16(portAsString), let ed25519PublicKey = rawTarget["pubkey_ed25519"] as? String, let x25519PublicKey = rawTarget["pubkey_x25519"] as? String, address != "0.0.0.0" else {
|
||||
print("[Loki] Failed to parse target from: \(rawTarget).")
|
||||
return nil
|
||||
}
|
||||
return LokiAPITarget(address: "https://\(address)", port: port, publicKeySet: LokiAPITarget.KeySet(idKey: idKey, encryptionKey: encryptionKey))
|
||||
return LokiAPITarget(address: "https://\(address)", port: port, publicKeySet: LokiAPITarget.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,8 +14,8 @@ internal final class LokiAPITarget : NSObject, NSCoding {
|
|||
}
|
||||
|
||||
internal struct KeySet {
|
||||
let idKey: String
|
||||
let encryptionKey: String
|
||||
let ed25519Key: String
|
||||
let x25519Key: String
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
@ -30,7 +30,7 @@ internal final class LokiAPITarget : NSObject, NSCoding {
|
|||
address = coder.decodeObject(forKey: "address") as! String
|
||||
port = coder.decodeObject(forKey: "port") as! UInt16
|
||||
if let idKey = coder.decodeObject(forKey: "idKey") as? String, let encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? String {
|
||||
publicKeySet = KeySet(idKey: idKey, encryptionKey: encryptionKey)
|
||||
publicKeySet = KeySet(ed25519Key: idKey, x25519Key: encryptionKey)
|
||||
} else {
|
||||
publicKeySet = nil
|
||||
}
|
||||
|
@ -41,8 +41,8 @@ internal final class LokiAPITarget : NSObject, NSCoding {
|
|||
coder.encode(address, forKey: "address")
|
||||
coder.encode(port, forKey: "port")
|
||||
if let keySet = publicKeySet {
|
||||
coder.encode(keySet.idKey, forKey: "idKey")
|
||||
coder.encode(keySet.encryptionKey, forKey: "encryptionKey")
|
||||
coder.encode(keySet.ed25519Key, forKey: "idKey")
|
||||
coder.encode(keySet.x25519Key, forKey: "encryptionKey")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ internal class LokiSnodeProxy : LokiHTTPClient {
|
|||
let headers = getCanonicalHeaders(for: request)
|
||||
return Promise<LokiAPI.RawResponse> { [target = self.target, keyPair = self.keyPair, httpSession = self.httpSession] seal in
|
||||
DispatchQueue.global().async {
|
||||
let uncheckedSymmetricKey = try? Curve25519.generateSharedSecret(fromPublicKey: Data(hex: targetHexEncodedPublicKeySet.encryptionKey), privateKey: keyPair.privateKey)
|
||||
let uncheckedSymmetricKey = try? Curve25519.generateSharedSecret(fromPublicKey: Data(hex: targetHexEncodedPublicKeySet.x25519Key), privateKey: keyPair.privateKey)
|
||||
guard let symmetricKey = uncheckedSymmetricKey else { return seal.reject(Error.symmetricKeyGenerationFailed) }
|
||||
LokiAPI.getRandomSnode().then(on: DispatchQueue.global()) { proxy -> Promise<LokiAPI.RawResponse> in
|
||||
let url = "\(proxy.address):\(proxy.port)/proxy"
|
||||
|
@ -49,7 +49,7 @@ internal class LokiSnodeProxy : LokiHTTPClient {
|
|||
let ivAndCipherText = try DiffieHellman.encrypt(proxyRequestParametersAsData, using: symmetricKey)
|
||||
let proxyRequestHeaders = [
|
||||
"X-Sender-Public-Key" : keyPair.publicKey.toHexString(),
|
||||
"X-Target-Snode-Key" : targetHexEncodedPublicKeySet.idKey
|
||||
"X-Target-Snode-Key" : targetHexEncodedPublicKeySet.ed25519Key
|
||||
]
|
||||
let (promise, resolver) = LokiAPI.RawResponsePromise.pending()
|
||||
let proxyRequest = AFHTTPRequestSerializer().request(withMethod: "POST", urlString: url, parameters: nil, error: nil)
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import CryptoSwift
|
||||
import PromiseKit
|
||||
|
||||
extension OnionRequestAPI {
|
||||
|
||||
internal typealias EncryptionResult = (ciphertext: Data, symmetricKey: Data, ephemeralPublicKey: Data)
|
||||
|
||||
/// Returns `size` bytes of random data generated using the default random number generator. See
|
||||
/// [SecRandomCopyBytes](https://developer.apple.com/documentation/security/1399291-secrandomcopybytes) for more information.
|
||||
private static func getRandomData(ofSize size: UInt) throws -> Data {
|
||||
var data = Data(count: Int(size))
|
||||
let result = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, Int(size), $0.baseAddress!) }
|
||||
guard result == errSecSuccess else { throw Error.randomDataGenerationFailed }
|
||||
return data
|
||||
}
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
private static func encrypt(_ plaintext: Data, usingAESGCMWithSymmetricKey symmetricKey: Data) throws -> Data {
|
||||
guard !Thread.isMainThread else { preconditionFailure("It's illegal to call encryptUsingAESGCM(symmetricKey:plainText:) from the main thread.") }
|
||||
let ivSize: UInt = 12
|
||||
let iv = try getRandomData(ofSize: ivSize)
|
||||
let gcmTagLength: UInt = 128
|
||||
let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagLength), mode: .combined)
|
||||
let aes = try AES(key: symmetricKey.bytes, blockMode: gcm, padding: .noPadding)
|
||||
let ciphertext = try aes.encrypt(plaintext.bytes)
|
||||
return Data(bytes: ciphertext)
|
||||
}
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
private static func encrypt(_ plaintext: Data, forSnode snode: LokiAPITarget) throws -> EncryptionResult {
|
||||
guard !Thread.isMainThread else { preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") }
|
||||
guard let hexEncodedSnodeX25519PublicKey = snode.publicKeySet?.x25519Key else { throw Error.snodePublicKeySetMissing }
|
||||
let snodeX25519PublicKey = Data(hex: hexEncodedSnodeX25519PublicKey)
|
||||
let ephemeralKeyPair = Curve25519.generateKeyPair()
|
||||
let ephemeralSharedSecret = try Curve25519.generateSharedSecret(fromPublicKey: snodeX25519PublicKey, privateKey: ephemeralKeyPair.privateKey)
|
||||
let password = "LOKI"
|
||||
let key = try HKDF(password: password.bytes, variant: .sha256).calculate()
|
||||
let symmetricKey = try HMAC(key: key, variant: .sha256).authenticate(ephemeralSharedSecret.bytes)
|
||||
let ciphertext = try encrypt(plaintext, usingAESGCMWithSymmetricKey: Data(bytes: symmetricKey))
|
||||
return (ciphertext, Data(bytes: symmetricKey), ephemeralKeyPair.publicKey)
|
||||
}
|
||||
|
||||
/// Encrypts `payload` for `snode` and returns the result. Use this to build the core of an onion request.
|
||||
internal static func encrypt(_ payload: Data, forTargetSnode snode: LokiAPITarget) -> Promise<EncryptionResult> {
|
||||
let (promise, seal) = Promise<EncryptionResult>.pending()
|
||||
workQueue.async {
|
||||
let parameters: JSON = [ "body" : payload ]
|
||||
do {
|
||||
let plaintext = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||
let result = try encrypt(plaintext, forSnode: snode)
|
||||
seal.fulfill(result)
|
||||
} catch (let error) {
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Encrypts the previous encryption result (i.e. that of the hop after this one) for the given hop. Use this to build the layers of an onion request.
|
||||
internal static func encryptHop(from snode1: LokiAPITarget, to snode2: LokiAPITarget, using previousEncryptionResult: EncryptionResult) -> Promise<EncryptionResult> {
|
||||
let (promise, seal) = Promise<EncryptionResult>.pending()
|
||||
workQueue.async {
|
||||
let parameters: JSON = [
|
||||
"ciphertext" : previousEncryptionResult.ciphertext.base64EncodedString(),
|
||||
"ephemeral_key" : previousEncryptionResult.ephemeralPublicKey.toHexString(),
|
||||
"destination" : snode2.publicKeySet!.ed25519Key
|
||||
]
|
||||
do {
|
||||
let plaintext = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||
let result = try encrypt(plaintext, forSnode: snode1)
|
||||
seal.fulfill(result)
|
||||
} catch (let error) {
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
}
|
|
@ -1,48 +1,44 @@
|
|||
import PromiseKit
|
||||
import SignalMetadataKit
|
||||
|
||||
// TODO: Test path snodes as well
|
||||
|
||||
/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
|
||||
internal enum OnionRequestAPI {
|
||||
private static let urlSessionDelegate = URLSessionDelegateImplementation()
|
||||
private static let urlSession = URLSession(configuration: .ephemeral, delegate: urlSessionDelegate, delegateQueue: nil)
|
||||
|
||||
/// - Note: Exposed for testing purposes.
|
||||
internal static let workQueue = DispatchQueue.global() // TODO: We should probably move away from using the global queue for this
|
||||
internal static var guardSnodes: Set<LokiAPITarget> = []
|
||||
internal static var paths: Set<Path> = []
|
||||
|
||||
private static let httpSession: AFHTTPSessionManager = {
|
||||
let result = AFHTTPSessionManager(sessionConfiguration: .ephemeral)
|
||||
let securityPolicy = AFSecurityPolicy.default()
|
||||
securityPolicy.allowInvalidCertificates = true
|
||||
securityPolicy.validatesDomainName = false // TODO: Do we need this?
|
||||
result.securityPolicy = securityPolicy
|
||||
result.responseSerializer = AFHTTPResponseSerializer()
|
||||
result.completionQueue = workQueue
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let pathCount: UInt = 3
|
||||
/// The number of snodes (including the guard snode) in a path.
|
||||
private static let pathSize: UInt = 3
|
||||
private static let guardSnodeCount: UInt = 3
|
||||
|
||||
// MARK: URL Session Delegate Implementation
|
||||
private final class URLSessionDelegateImplementation : 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: Error
|
||||
internal enum Error : LocalizedError {
|
||||
case insufficientSnodes
|
||||
case snodePublicKeySetMissing
|
||||
case symmetricKeyGenerationFailed
|
||||
case jsonSerializationFailed
|
||||
case encryptionFailed
|
||||
case randomDataGenerationFailed
|
||||
case generic
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .insufficientSnodes: return "Couldn't find enough snodes to build a path."
|
||||
case .snodePublicKeySetMissing: return "Missing snode public key set."
|
||||
case .symmetricKeyGenerationFailed: return "Couldn't generate symmetric key."
|
||||
case .jsonSerializationFailed: return "Couldn't serialize JSON."
|
||||
case .encryptionFailed: return "Couldn't encrypt request."
|
||||
case .randomDataGenerationFailed: return "Couldn't generate random data."
|
||||
case .generic: return "An error occurred."
|
||||
}
|
||||
}
|
||||
|
@ -61,7 +57,7 @@ internal enum OnionRequestAPI {
|
|||
return LokiAPI.invoke(.getSwarm, on: snode, associatedWith: hexEncodedPublicKey, parameters: parameters, timeout: timeout).map(on: workQueue) { _ in }
|
||||
}
|
||||
|
||||
/// Finds `guardSnodeCount` guard snodes to use for path building. The returned promise may error out with `Error.insufficientSnodes`
|
||||
/// Finds `guardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
private static func getGuardSnodes() -> Promise<Set<LokiAPITarget>> {
|
||||
if !guardSnodes.isEmpty {
|
||||
|
@ -98,7 +94,7 @@ internal enum OnionRequestAPI {
|
|||
}
|
||||
}
|
||||
|
||||
/// Builds and returns `pathCount` paths. The returned promise may error out with `Error.insufficientSnodes`
|
||||
/// Builds and returns `pathCount` paths. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
private static func buildPaths() -> Promise<Set<Path>> {
|
||||
print("[Loki] [Onion Request API] Building onion request paths.")
|
||||
|
@ -110,6 +106,7 @@ internal enum OnionRequestAPI {
|
|||
guard unusedSnodes.count >= minSnodeCount else { throw Error.insufficientSnodes }
|
||||
let result: Set<Path> = Set(guardSnodes.map { guardSnode in
|
||||
// Force unwrapping is safe because of the minSnodeCount check above
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
return [ guardSnode ] + (0..<(pathSize - 1)).map { _ in unusedSnodes.randomElement()! }
|
||||
})
|
||||
print("[Loki] [Onion Request API] Built new onion request paths: \(result.map { "\($0.description)" }.joined(separator: ", "))")
|
||||
|
@ -118,58 +115,10 @@ internal enum OnionRequestAPI {
|
|||
}
|
||||
}
|
||||
|
||||
private static func getCanonicalHeaders(for request: TSRequest) -> [String:Any] {
|
||||
guard let headers = request.allHTTPHeaderFields else { return [:] }
|
||||
return headers.mapValues { value in
|
||||
switch value.lowercased() {
|
||||
case "true": return true
|
||||
case "false": return false
|
||||
default: return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func encrypt(_ request: TSRequest, forSnode snode: LokiAPITarget) -> Promise<URLRequest> {
|
||||
let (promise, seal) = Promise<URLRequest>.pending()
|
||||
let headers = getCanonicalHeaders(for: request)
|
||||
workQueue.async {
|
||||
guard let snodeHexEncodedPublicKeySet = snode.publicKeySet else { return seal.reject(Error.snodePublicKeySetMissing) }
|
||||
let snodeEncryptionKey = Data(hex: snodeHexEncodedPublicKeySet.encryptionKey)
|
||||
let ephemeralKeyPair = Curve25519.generateKeyPair()
|
||||
let uncheckedSymmetricKey = try? Curve25519.generateSharedSecret(fromPublicKey: snodeEncryptionKey, privateKey: ephemeralKeyPair.privateKey)
|
||||
guard let symmetricKey = uncheckedSymmetricKey else { return seal.reject(Error.symmetricKeyGenerationFailed) }
|
||||
let url = "\(snode.address):\(snode.port)/onion_req"
|
||||
guard let parametersAsData = try? JSONSerialization.data(withJSONObject: request.parameters, options: []) else { return seal.reject(Error.jsonSerializationFailed) }
|
||||
let onionRequestParameters: JSON = [
|
||||
"method" : request.httpMethod,
|
||||
"body" : String(bytes: parametersAsData, encoding: .utf8),
|
||||
"headers" : headers
|
||||
]
|
||||
guard let onionRequestParametersAsData = try? JSONSerialization.data(withJSONObject: onionRequestParameters, options: []) else { return seal.reject(Error.jsonSerializationFailed) }
|
||||
guard let ivAndCipherText = try? DiffieHellman.encrypt(onionRequestParametersAsData, using: symmetricKey) else { return seal.reject(Error.encryptionFailed) }
|
||||
let onionRequestHeaders = [
|
||||
"X-Sender-Public-Key" : ephemeralKeyPair.publicKey.toHexString(),
|
||||
"X-Target-Snode-Key" : snodeHexEncodedPublicKeySet.idKey
|
||||
]
|
||||
let onionRequest = AFHTTPRequestSerializer().request(withMethod: "POST", urlString: url, parameters: nil, error: nil) as URLRequest
|
||||
seal.fulfill(onionRequest)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
private static func encrypt(_ request: TSRequest, forTargetSnode snode: LokiAPITarget) -> Promise<TSRequest> {
|
||||
return Promise<TSRequest> { $0.fulfill(request) }
|
||||
}
|
||||
|
||||
private static func encrypt(_ request: TSRequest, forRelayFrom snode1: LokiAPITarget, to snode2: LokiAPITarget) -> Promise<TSRequest> {
|
||||
return Promise<TSRequest> { $0.fulfill(request) }
|
||||
}
|
||||
|
||||
// MARK: Internal API
|
||||
/// Returns an `OnionRequestPath` to be used for onion requests. Builds new paths as needed.
|
||||
/// Returns a `Path` to be used for onion requests. Builds paths as needed.
|
||||
///
|
||||
/// - Note: Should ideally only ever be invoked from `DispatchQueue.global()`.
|
||||
internal static func getPath() -> Promise<Path> {
|
||||
private static func getPath() -> Promise<Path> {
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
if paths.count >= pathCount {
|
||||
return Promise<Path> { $0.fulfill(paths.randomElement()!) }
|
||||
|
@ -182,38 +131,42 @@ internal enum OnionRequestAPI {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sends an onion request to `snode`. Builds paths as needed.
|
||||
internal static func send(_ request: TSRequest, to snode: LokiAPITarget) -> Promise<Any> {
|
||||
var request = request
|
||||
return getPath().then(on: workQueue) { path -> Promise<TSRequest> in
|
||||
var path = path
|
||||
path.removeFirst() // Drop the guard snode
|
||||
return encrypt(request, forTargetSnode: snode).then(on: workQueue) { r -> Promise<TSRequest> in
|
||||
request = r
|
||||
/// Builds an onion around `payload` and returns the result.
|
||||
private static func buildOnion(around payload: Data, targetedAt snode: LokiAPITarget) -> Promise<(guardSnode: LokiAPITarget, onion: Data)> {
|
||||
var guardSnode: LokiAPITarget!
|
||||
return getPath().then(on: workQueue) { path -> Promise<EncryptionResult> in
|
||||
guardSnode = path.first!
|
||||
return encrypt(payload, forTargetSnode: snode).then(on: workQueue) { r -> Promise<EncryptionResult> in
|
||||
var path = path
|
||||
var encryptionResult = r
|
||||
var rhs = snode
|
||||
func encryptForNextLayer() -> Promise<TSRequest> {
|
||||
func addLayer() -> Promise<EncryptionResult> {
|
||||
if path.isEmpty {
|
||||
return Promise<TSRequest> { $0.fulfill(request) }
|
||||
return Promise<EncryptionResult> { $0.fulfill(encryptionResult) }
|
||||
} else {
|
||||
let lhs = path.removeLast()
|
||||
return encrypt(request, forRelayFrom: lhs, to: rhs).then(on: workQueue) { r -> Promise<TSRequest> in
|
||||
request = r
|
||||
return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then(on: workQueue) { e -> Promise<EncryptionResult> in
|
||||
encryptionResult = e
|
||||
rhs = lhs
|
||||
return encryptForNextLayer()
|
||||
return addLayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
return encryptForNextLayer()
|
||||
return addLayer()
|
||||
}
|
||||
}.then { request -> Promise<Any> in
|
||||
let (promise, seal) = LokiAPI.RawResponsePromise.pending()
|
||||
var task: URLSessionDataTask!
|
||||
task = httpSession.dataTask(with: request as URLRequest) { response, result, error in
|
||||
}.map { (guardSnode: guardSnode, onion: $0.ciphertext) }
|
||||
}
|
||||
|
||||
// MARK: Internal API
|
||||
/// Sends an onion request to `snode`. Builds paths as needed.
|
||||
internal static func send(_ request: URLRequest, to snode: LokiAPITarget) -> Promise<Any> {
|
||||
return buildOnion(around: request.httpBody!, targetedAt: snode).then(on: workQueue) { intermediate -> Promise<Any> in
|
||||
let guardSnode = intermediate.guardSnode
|
||||
let onion = intermediate.onion
|
||||
let (promise, seal) = Promise<Any>.pending()
|
||||
let task = urlSession.dataTask(with: request) { response, result, error in
|
||||
if let error = error {
|
||||
let nmError = NetworkManagerError.taskError(task: task, underlyingError: error)
|
||||
let nsError = nmError as NSError
|
||||
nsError.isRetryable = false
|
||||
seal.reject(nsError)
|
||||
seal.reject(error)
|
||||
} else if let result = result {
|
||||
seal.fulfill(result)
|
||||
} else {
|
||||
|
|
Loading…
Reference in a new issue