From 2b1e32283279d7b44469687111842da75d103d27 Mon Sep 17 00:00:00 2001 From: nielsandriesse Date: Thu, 5 Nov 2020 12:07:21 +1100 Subject: [PATCH] Create SessionSnodeKit --- Podfile | 15 + Podfile.lock | 8 +- Pods | 2 +- SessionSnodeKit/Configuration.swift | 13 + SessionSnodeKit/HTTP.swift | 108 +++++ SessionSnodeKit/Message.swift | 25 + SessionSnodeKit/Meta/Info.plist | 22 + SessionSnodeKit/Meta/SessionSnodeKit.h | 4 + SessionSnodeKit/Notification+Session.swift | 8 + .../OnionRequestAPI+Encryption.swift | 72 +++ SessionSnodeKit/OnionRequestAPI.swift | 427 ++++++++++++++++++ SessionSnodeKit/Snode.swift | 55 +++ SessionSnodeKit/SnodeAPI.swift | 316 +++++++++++++ SessionSnodeKit/Storage.swift | 18 + SessionSnodeKit/Utilities/AESGCM.swift | 69 +++ .../Utilities/Array+Utilities.swift | 7 + .../Utilities/Data+Utilities.swift | 32 ++ .../Utilities/Dictionary+Utilities.swift | 13 + SessionSnodeKit/Utilities/JSON.swift | 2 + SessionSnodeKit/Utilities/Logging.swift | 6 + .../Utilities/Promise+Delaying.swift | 13 + .../Utilities/Promise+Hashing.swift | 13 + .../Utilities/Promise+Retrying.swift | 14 + .../Utilities/Promise+Threading.swift | 91 ++++ .../Utilities/String+Utilities.swift | 9 + SessionSnodeKit/Utilities/Threading.swift | 6 + Signal.xcodeproj/project.pbxproj | 354 +++++++++++++++ 27 files changed, 1720 insertions(+), 2 deletions(-) create mode 100644 SessionSnodeKit/Configuration.swift create mode 100644 SessionSnodeKit/HTTP.swift create mode 100644 SessionSnodeKit/Message.swift create mode 100644 SessionSnodeKit/Meta/Info.plist create mode 100644 SessionSnodeKit/Meta/SessionSnodeKit.h create mode 100644 SessionSnodeKit/Notification+Session.swift create mode 100644 SessionSnodeKit/OnionRequestAPI+Encryption.swift create mode 100644 SessionSnodeKit/OnionRequestAPI.swift create mode 100644 SessionSnodeKit/Snode.swift create mode 100644 SessionSnodeKit/SnodeAPI.swift create mode 100644 SessionSnodeKit/Storage.swift create mode 100644 SessionSnodeKit/Utilities/AESGCM.swift create mode 100644 SessionSnodeKit/Utilities/Array+Utilities.swift create mode 100644 SessionSnodeKit/Utilities/Data+Utilities.swift create mode 100644 SessionSnodeKit/Utilities/Dictionary+Utilities.swift create mode 100644 SessionSnodeKit/Utilities/JSON.swift create mode 100644 SessionSnodeKit/Utilities/Logging.swift create mode 100644 SessionSnodeKit/Utilities/Promise+Delaying.swift create mode 100644 SessionSnodeKit/Utilities/Promise+Hashing.swift create mode 100644 SessionSnodeKit/Utilities/Promise+Retrying.swift create mode 100644 SessionSnodeKit/Utilities/Promise+Threading.swift create mode 100644 SessionSnodeKit/Utilities/String+Utilities.swift create mode 100644 SessionSnodeKit/Utilities/Threading.swift diff --git a/Podfile b/Podfile index 22cc0d46e..feb466143 100644 --- a/Podfile +++ b/Podfile @@ -105,9 +105,16 @@ target 'SignalMessaging' do shared_pods end +target 'SessionSnodeKit' do + pod 'CryptoSwift', :inhibit_warnings => true + pod 'Curve25519Kit', :inhibit_warnings => true + pod 'PromiseKit', :inhibit_warnings => true +end + post_install do |installer| enable_whole_module_optimization_for_cryptoswift(installer) enable_extension_support_for_purelayout(installer) + set_minimum_deployment_target(installer) end def enable_whole_module_optimization_for_cryptoswift(installer) @@ -133,3 +140,11 @@ def enable_extension_support_for_purelayout(installer) end end end + +def set_minimum_deployment_target(installer) + installer.pods_project.targets.each do |target| + target.build_configurations.each do |build_configuration| + build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' + end + end +end diff --git a/Podfile.lock b/Podfile.lock index 03c7d573d..1169e7a5d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -18,6 +18,7 @@ PODS: - CocoaLumberjack/Core (= 3.6.2) - CocoaLumberjack/Core (3.6.2) - CryptoSwift (1.3.2) + - Curve25519Kit (2.1.0) - FeedKit (8.1.1) - GRKOpenSSLFramework (1.0.2.12) - libPhoneNumber-iOS (0.9.15) @@ -199,11 +200,14 @@ PODS: DEPENDENCIES: - AFNetworking (~> 3.2.1) + - CryptoSwift - CryptoSwift (~> 1.3) + - Curve25519Kit - FeedKit (~> 8.1) - GRKOpenSSLFramework (from `https://github.com/signalapp/GRKOpenSSLFramework`) - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - NVActivityIndicatorView (~> 4.7) + - PromiseKit - PromiseKit (= 6.5.3) - PureLayout (~> 3.1.4) - Reachability @@ -232,6 +236,7 @@ SPEC REPOS: - AFNetworking - CocoaLumberjack - CryptoSwift + - Curve25519Kit - FeedKit - libPhoneNumber-iOS - NVActivityIndicatorView @@ -309,6 +314,7 @@ SPEC CHECKSUMS: AFNetworking: b6f891fdfaed196b46c7a83cf209e09697b94057 CocoaLumberjack: bd155f2dd06c0e0b03f876f7a3ee55693122ec94 CryptoSwift: 093499be1a94b0cae36e6c26b70870668cb56060 + Curve25519Kit: 76d0859ecb34704f7732847812363f83b23a6a59 FeedKit: 3418eed25f0b493b205b4de1b8511ac21d413fa9 GRKOpenSSLFramework: 8a3735ad41e7dc1daff460467bccd32ca5d6ae3e libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 @@ -333,6 +339,6 @@ SPEC CHECKSUMS: YYImage: 6db68da66f20d9f169ceb94dfb9947c3867b9665 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 8b58cc2d282fa528aa81b128b643596a9059c270 +PODFILE CHECKSUM: a046210e9d91429c33c0fa60f7ff0362c6994f5d COCOAPODS: 1.10.0.rc.1 diff --git a/Pods b/Pods index d1b2c2c2f..5ba5f8d5a 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit d1b2c2c2fe1b47ab1314192e9320f6cbc30871be +Subproject commit 5ba5f8d5a001bbf4d925ef0f246bf402e03b097d diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift new file mode 100644 index 000000000..a34982fd7 --- /dev/null +++ b/SessionSnodeKit/Configuration.swift @@ -0,0 +1,13 @@ + +public struct Configuration { + public let storage: Storage + + internal static var shared: Configuration! +} + +public enum SessionSnodeKit { // Just to make the external API nice + + public static func configure(with configuration: Configuration) { + Configuration.shared = configuration + } +} diff --git a/SessionSnodeKit/HTTP.swift b/SessionSnodeKit/HTTP.swift new file mode 100644 index 000000000..cdaffb766 --- /dev/null +++ b/SessionSnodeKit/HTTP.swift @@ -0,0 +1,108 @@ +import Foundation +import PromiseKit + +public enum HTTP { + private static let seedNodeURLSession = URLSession(configuration: .ephemeral) + private static let defaultURLSession = URLSession(configuration: .ephemeral, delegate: defaultURLSessionDelegate, delegateQueue: nil) + private static let defaultURLSessionDelegate = DefaultURLSessionDelegateImplementation() + + // MARK: Settings + public static let timeout: TimeInterval = 10 + + // MARK: URL Session Delegate Implementation + private final class DefaultURLSessionDelegateImplementation : 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 { + case get = "GET" + case put = "PUT" + case post = "POST" + case delete = "DELETE" + } + + // MARK: Error + public enum Error : LocalizedError { + case generic + case httpRequestFailed(statusCode: UInt, json: JSON?) + case invalidJSON + + public var errorDescription: String? { + switch self { + case .generic: return "An error occurred." + case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." + case .invalidJSON: return "Invalid JSON." + } + } + } + + // MARK: Main + public static func execute(_ verb: Verb, _ url: String, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { + 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 { + 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 { + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = verb.rawValue + request.httpBody = body + request.timeoutInterval = timeout + request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent") + let (promise, seal) = Promise.pending() + let urlSession = useSeedNodeURLSession ? seedNodeURLSession : defaultURLSession + 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:) + return seal.reject(Error.httpRequestFailed(statusCode: 0, json: 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, json: nil)) + } + let statusCode = UInt(response.statusCode) + var json: JSON? = nil + if let j = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON { + json = j + } else if let result = String(data: data, encoding: .utf8) { + json = [ "result" : result ] + } + guard 200...299 ~= statusCode else { + let jsonDescription = 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, json: json)) + } + if let json = json { + seal.fulfill(json) + } else { + SNLog("Couldn't parse JSON returned by \(verb.rawValue) request to \(url).") + return seal.reject(Error.invalidJSON) + } + } + task.resume() + return promise + } +} diff --git a/SessionSnodeKit/Message.swift b/SessionSnodeKit/Message.swift new file mode 100644 index 000000000..b60e5b1ab --- /dev/null +++ b/SessionSnodeKit/Message.swift @@ -0,0 +1,25 @@ +import PromiseKit + +public struct Message { + /// The hex encoded public key of the recipient. + let recipientPublicKey: String + /// The content of the message. + let data: LosslessStringConvertible + /// The time to live for the message in milliseconds. + let ttl: UInt64 + /// When the proof of work was calculated. + /// + /// - Note: Expressed as milliseconds since 00:00:00 UTC on 1 January 1970. + let timestamp: UInt64? = nil + /// The base 64 encoded proof of work. + let nonce: String? = nil + + public func toJSON() -> JSON { + var result = [ "pubKey" : recipientPublicKey, "data" : data.description, "ttl" : String(ttl) ] + if let timestamp = timestamp, let nonce = nonce { + result["timestamp"] = String(timestamp) + result["nonce"] = nonce + } + return result + } +} diff --git a/SessionSnodeKit/Meta/Info.plist b/SessionSnodeKit/Meta/Info.plist new file mode 100644 index 000000000..9bcb24442 --- /dev/null +++ b/SessionSnodeKit/Meta/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/SessionSnodeKit/Meta/SessionSnodeKit.h b/SessionSnodeKit/Meta/SessionSnodeKit.h new file mode 100644 index 000000000..698aa516f --- /dev/null +++ b/SessionSnodeKit/Meta/SessionSnodeKit.h @@ -0,0 +1,4 @@ +#import + +FOUNDATION_EXPORT double SessionSnodeKitVersionNumber; +FOUNDATION_EXPORT const unsigned char SessionSnodeKitVersionString[]; diff --git a/SessionSnodeKit/Notification+Session.swift b/SessionSnodeKit/Notification+Session.swift new file mode 100644 index 000000000..93dc7b042 --- /dev/null +++ b/SessionSnodeKit/Notification+Session.swift @@ -0,0 +1,8 @@ +import Foundation + +public extension Notification.Name { + + static let buildingPaths = Notification.Name("buildingPaths") + static let pathsBuilt = Notification.Name("pathsBuilt") + static let onionRequestPathCountriesLoaded = Notification.Name("onionRequestPathCountriesLoaded") +} diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift new file mode 100644 index 000000000..8fefff13a --- /dev/null +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -0,0 +1,72 @@ +import CryptoSwift +import PromiseKit + +internal extension OnionRequestAPI { + + static func encode(ciphertext: Data, json: JSON) throws -> Data { + // The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 | + guard JSONSerialization.isValidJSONObject(json) else { throw HTTP.Error.invalidJSON } + let jsonAsData = try JSONSerialization.data(withJSONObject: json, options: [ .fragmentsAllowed ]) + let ciphertextSize = Int32(ciphertext.count).littleEndian + let ciphertextSizeAsData = withUnsafePointer(to: ciphertextSize) { Data(bytes: $0, count: MemoryLayout.size) } + return ciphertextSizeAsData + ciphertext + jsonAsData + } + + /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. + static func encrypt(_ payload: JSON, for destination: Destination) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + do { + guard JSONSerialization.isValidJSONObject(payload) else { return seal.reject(HTTP.Error.invalidJSON) } + // Wrapping isn't needed for file server or open group onion requests + switch destination { + case .snode(let snode): + let snodeX25519PublicKey = snode.publicKeySet.x25519Key + let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) + let plaintext = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) + let result = try AESGCM.encrypt(plaintext, for: snodeX25519PublicKey) + seal.fulfill(result) + case .server(_, let serverX25519PublicKey): + let plaintext = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) + let result = try AESGCM.encrypt(plaintext, for: serverX25519PublicKey) + 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 this hop. Use this to build the layers of an onion request. + static func encryptHop(from lhs: Destination, to rhs: Destination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + var parameters: JSON + switch rhs { + case .snode(let snode): + let snodeED25519PublicKey = snode.publicKeySet.ed25519Key + parameters = [ "destination" : snodeED25519PublicKey ] + case .server(let host, _): + parameters = [ "host" : host, "target" : "/loki/v2/lsrpc", "method" : "POST" ] + } + parameters["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() + let x25519PublicKey: String + switch lhs { + case .snode(let snode): + let snodeX25519PublicKey = snode.publicKeySet.x25519Key + x25519PublicKey = snodeX25519PublicKey + case .server(_, let serverX25519PublicKey): + x25519PublicKey = serverX25519PublicKey + } + do { + let plaintext = try encode(ciphertext: previousEncryptionResult.ciphertext, json: parameters) + let result = try AESGCM.encrypt(plaintext, for: x25519PublicKey) + seal.fulfill(result) + } catch (let error) { + seal.reject(error) + } + } + return promise + } +} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift new file mode 100644 index 000000000..aa5d5dab7 --- /dev/null +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -0,0 +1,427 @@ +import CryptoSwift +import PromiseKit + +/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. +public enum OnionRequestAPI { + private static var pathFailureCount: [Path:UInt] = [:] + private static var snodeFailureCount: [Snode:UInt] = [:] + public static var guardSnodes: Set = [] + // TODO: Just get/set paths from/in the database directly? + public static var paths: [Path] = [] // Not a set to ensure we consistently show the same path to the user + + // MARK: Settings + public static let maxFileSize = 10_000_000 // 10 MB + /// The number of snodes (including the guard snode) in a path. + private static let pathSize: UInt = 3 + /// The number of times a path can fail before it's replaced. + private static let pathFailureThreshold: UInt = 3 + /// The number of times a snode can fail before it's replaced. + private static let snodeFailureThreshold: UInt = 3 + /// The number of paths to maintain. + public static let targetPathCount: UInt = 2 + + /// The number of guard snodes required to maintain `targetPathCount` paths. + private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path + + // MARK: Destination + internal enum Destination { + case snode(Snode) + case server(host: String, x25519PublicKey: String) + } + + // MARK: Error + public enum Error : LocalizedError { + case httpRequestFailedAtDestination(statusCode: UInt, json: JSON) + case insufficientSnodes + case invalidURL + case missingSnodeVersion + case snodePublicKeySetMissing + case unsupportedSnodeVersion(String) + + public var errorDescription: String? { + switch self { + case .httpRequestFailedAtDestination(let statusCode, _): return "HTTP request failed at destination with status code: \(statusCode)." + case .insufficientSnodes: return "Couldn't find enough snodes to build a path." + case .invalidURL: return "Invalid URL" + case .missingSnodeVersion: return "Missing snode version." + case .snodePublicKeySetMissing: return "Missing snode public key set." + case .unsupportedSnodeVersion(let version): return "Unsupported snode version: \(version)." + } + } + } + + // MARK: Path + public typealias Path = [Snode] + + // MARK: Onion Building Result + private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) + + // MARK: Private API + /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. + private static func testSnode(_ snode: Snode) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + let url = "\(snode.address):\(snode.port)/get_stats/v1" + let timeout: TimeInterval = 3 // Use a shorter timeout for testing + HTTP.execute(.get, url, timeout: timeout).done2 { json in + guard let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } + if version >= "2.0.7" { + seal.fulfill(()) + } else { + SNLog("Unsupported snode version: \(version).") + seal.reject(Error.unsupportedSnodeVersion(version)) + } + }.catch2 { error in + seal.reject(error) + } + } + return promise + } + + /// Finds `targetGuardSnodeCount` 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(reusing reusableGuardSnodes: [Snode]) -> Promise> { + if guardSnodes.count >= targetGuardSnodeCount { + return Promise> { $0.fulfill(guardSnodes) } + } else { + SNLog("Populating guard snode cache.") + return SnodeAPI.getRandomSnode().then2 { _ -> Promise> in // Just used to populate the snode pool + var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) // Sync on LokiAPI.workQueue + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { throw Error.insufficientSnodes } + func getGuardSnode() -> Promise { + // randomElement() uses the system's default random generator, which is cryptographically secure + guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } + unusedSnodes.remove(candidate) // All used snodes should be unique + SNLog("Testing guard snode: \(candidate).") + // Loop until a reliable guard snode is found + return testSnode(candidate).map2 { candidate }.recover(on: DispatchQueue.main) { _ in + withDelay(0.1, completionQueue: Threading.workQueue) { getGuardSnode() } + } + } + let promises = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)).map { _ in getGuardSnode() } + return when(fulfilled: promises).map2 { guardSnodes in + let guardSnodesAsSet = Set(guardSnodes + reusableGuardSnodes) + OnionRequestAPI.guardSnodes = guardSnodesAsSet + return guardSnodesAsSet + } + } + } + } + + /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` + /// if not enough (reliable) snodes are available. + @discardableResult + private static func buildPaths(reusing reusablePaths: [Path]) -> Promise<[Path]> { + SNLog("Building onion request paths.") + DispatchQueue.main.async { + NotificationCenter.default.post(name: .buildingPaths, object: nil) + } + return SnodeAPI.getRandomSnode().then2 { _ -> Promise<[Path]> in // Just used to populate the snode pool + let reusableGuardSnodes = reusablePaths.map { $0[0] } + return getGuardSnodes(reusing: reusableGuardSnodes).map2 { guardSnodes -> [Path] in + var unusedSnodes = SnodeAPI.snodePool.subtracting(guardSnodes).subtracting(reusablePaths.flatMap { $0 }) + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) + guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } + // Don't test path snodes as this would reveal the user's IP to them + return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in + let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in + // randomElement() uses the system's default random generator, which is cryptographically secure + let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above + unusedSnodes.remove(pathSnode) // All used snodes should be unique + return pathSnode + } + SNLog("Built new onion request path: \(result.prettifiedDescription).") + return result + } + }.map2 { paths in + OnionRequestAPI.paths = paths + reusablePaths + + Configuration.shared.storage.with { transaction in + SNLog("Persisting onion request paths to database.") + Configuration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + } + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + return paths + } + } + } + + /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. + private static func getPath(excluding snode: Snode?) -> Promise { + guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } + var paths = OnionRequestAPI.paths + if paths.isEmpty { + paths = Configuration.shared.storage.getOnionRequestPaths() + OnionRequestAPI.paths = paths + if !paths.isEmpty { + guardSnodes.formUnion([ paths[0][0] ]) + if paths.count >= 2 { + guardSnodes.formUnion([ paths[1][0] ]) + } + } + } + // randomElement() uses the system's default random generator, which is cryptographically secure + if paths.count >= targetPathCount { + if let snode = snode { + return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } + } else { + return Promise { $0.fulfill(paths.randomElement()!) } + } + } else if !paths.isEmpty { + if let snode = snode { + if let path = paths.first(where: { !$0.contains(snode) }) { + buildPaths(reusing: paths) // Re-build paths in the background + return Promise { $0.fulfill(path) } + } else { + return buildPaths(reusing: paths).map2 { paths in + return paths.filter { !$0.contains(snode) }.randomElement()! + } + } + } else { + buildPaths(reusing: paths) // Re-build paths in the background + return Promise { $0.fulfill(paths.randomElement()!) } + } + } else { + return buildPaths(reusing: []).map2 { paths in + if let snode = snode { + return paths.filter { !$0.contains(snode) }.randomElement()! + } else { + return paths.randomElement()! + } + } + } + } + + private static func dropGuardSnode(_ snode: Snode) { + guardSnodes = guardSnodes.filter { $0 != snode } + } + + private static func drop(_ snode: Snode) throws { + // We repair the path here because we can do it sync. In the case where we drop a whole + // path we leave the re-building up to getPath(excluding:) because re-building the path + // in that case is async. + OnionRequestAPI.snodeFailureCount[snode] = 0 + var oldPaths = paths + guard let pathIndex = oldPaths.firstIndex(where: { $0.contains(snode) }) else { return } + var path = oldPaths[pathIndex] + guard let snodeIndex = path.firstIndex(of: snode) else { return } + path.remove(at: snodeIndex) + let unusedSnodes = SnodeAPI.snodePool.subtracting(oldPaths.flatMap { $0 }) + guard !unusedSnodes.isEmpty else { throw Error.insufficientSnodes } + // randomElement() uses the system's default random generator, which is cryptographically secure + path.append(unusedSnodes.randomElement()!) + // Don't test the new snode as this would reveal the user's IP + oldPaths.remove(at: pathIndex) + let newPaths = oldPaths + [ path ] + paths = newPaths + Configuration.shared.storage.with { transaction in + SNLog("Persisting onion request paths to database.") + Configuration.shared.storage.setOnionRequestPaths(to: newPaths, using: transaction) + } + } + + private static func drop(_ path: Path) { + OnionRequestAPI.pathFailureCount[path] = 0 + var paths = OnionRequestAPI.paths + guard let pathIndex = paths.firstIndex(of: path) else { return } + paths.remove(at: pathIndex) + OnionRequestAPI.paths = paths + Configuration.shared.storage.with { transaction in + if !paths.isEmpty { + SNLog("Persisting onion request paths to database.") + Configuration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + } else { + SNLog("Clearing onion request paths.") + Configuration.shared.storage.setOnionRequestPaths(to: [], using: transaction) + } + } + } + + /// Builds an onion around `payload` and returns the result. + private static func buildOnion(around payload: JSON, targetedAt destination: Destination) -> Promise { + var guardSnode: Snode! + var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination + var encryptionResult: AESGCM.EncryptionResult! + var snodeToExclude: Snode? + if case .snode(let snode) = destination { snodeToExclude = snode } + return getPath(excluding: snodeToExclude).then2 { path -> Promise in + guardSnode = path.first! + // Encrypt in reverse order, i.e. the destination first + return encrypt(payload, for: destination).then2 { r -> Promise in + targetSnodeSymmetricKey = r.symmetricKey + // Recursively encrypt the layers of the onion (again in reverse order) + encryptionResult = r + var path = path + var rhs = destination + func addLayer() -> Promise { + if path.isEmpty { + return Promise { $0.fulfill(encryptionResult) } + } else { + let lhs = Destination.snode(path.removeLast()) + return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise in + encryptionResult = r + rhs = lhs + return addLayer() + } + } + } + return addLayer() + } + }.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } + } + + // MARK: Internal API + /// Sends an onion request to `snode`. Builds new paths as needed. + internal static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String) -> Promise { + let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] + return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in + guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error + } + } + + /// Sends an onion request to `server`. Builds new paths as needed. + internal static func sendOnionRequest(_ request: NSURLRequest, to server: String, using x25519PublicKey: String, isJSONRequired: Bool = true) -> Promise { + var rawHeaders = request.allHTTPHeaderFields ?? [:] + rawHeaders.removeValue(forKey: "User-Agent") + var headers: JSON = rawHeaders.mapValues { value in + switch value.lowercased() { + case "true": return true + case "false": return false + default: return value + } + } + guard let url = request.url?.absoluteString, let host = request.url?.host else { return Promise(error: Error.invalidURL) } + var endpoint = "" + if server.count < url.count { + guard let serverEndIndex = url.range(of: server)?.upperBound else { return Promise(error: Error.invalidURL) } + let endpointStartIndex = url.index(after: serverEndIndex) + endpoint = String(url[endpointStartIndex.. Promise { + let (promise, seal) = Promise.pending() + var guardSnode: Snode! + Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` + buildOnion(around: payload, targetedAt: destination).done2 { intermediate in + guardSnode = intermediate.guardSnode + let url = "\(guardSnode.address):\(guardSnode.port)/onion_req/v2" + let finalEncryptionResult = intermediate.finalEncryptionResult + let onion = finalEncryptionResult.ciphertext + if case Destination.server = destination, Double(onion.count) > 0.75 * Double(maxFileSize) { + SNLog("Approaching request size limit: ~\(onion.count) bytes.") + } + let parameters: JSON = [ + "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() + ] + let body: Data + do { + body = try encode(ciphertext: onion, json: parameters) + } catch { + return seal.reject(error) + } + let destinationSymmetricKey = intermediate.destinationSymmetricKey + HTTP.execute(.post, url, body: body).done2 { json in + guard let base64EncodedIVAndCiphertext = json["result"] as? String, + let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidJSON) } + do { + let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, + let statusCode = json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) } + if statusCode == 406 { // Clock out of sync + SNLog("The user's clock is out of sync with the service node network.") + seal.reject(SnodeAPI.Error.clockOutOfSync) + } else if let bodyAsString = json["body"] as? String { + let body: JSON + if !isJSONRequired { + body = [ "result" : bodyAsString ] + } else { + guard let bodyAsData = bodyAsString.data(using: .utf8), + let b = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { return seal.reject(HTTP.Error.invalidJSON) } + body = b + } + guard 200...299 ~= statusCode else { return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body)) } + seal.fulfill(body) + } else { + guard 200...299 ~= statusCode else { return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json)) } + seal.fulfill(json) + } + } catch { + seal.reject(error) + } + }.catch2 { error in + seal.reject(error) + } + }.catch2 { error in + seal.reject(error) + } + } + promise.catch2 { error in // Must be invoked on LokiAPI.workQueue + guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error else { return } + let path = paths.first { $0.contains(guardSnode) } + func handleUnspecificError() { + guard let path = path else { return } + var pathFailureCount = OnionRequestAPI.pathFailureCount[path] ?? 0 + pathFailureCount += 1 + if pathFailureCount >= pathFailureThreshold { + dropGuardSnode(guardSnode) + path.forEach { snode in + SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + } + drop(path) + } else { + OnionRequestAPI.pathFailureCount[path] = pathFailureCount + } + } + let prefix = "Next node not found: " + if let message = json?["result"] as? String, message.hasPrefix(prefix) { + let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { + SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + do { + try drop(snode) + } catch { + handleUnspecificError() + } + } else { + OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount + } + } else { + handleUnspecificError() + } + } else if let message = json?["result"] as? String, message == "Loki Server error" { + // Do nothing + } else { + handleUnspecificError() + } + } + return promise + } +} diff --git a/SessionSnodeKit/Snode.swift b/SessionSnodeKit/Snode.swift new file mode 100644 index 000000000..878bd6392 --- /dev/null +++ b/SessionSnodeKit/Snode.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct Snode : Hashable, CustomStringConvertible { + public let address: String + public let port: UInt16 + public let publicKeySet: KeySet + + public var ip: String { + address.removingPrefix("https://") + } + + // MARK: Method + public enum Method : String { + case getSwarm = "get_snodes_for_pubkey" + case getMessages = "retrieve" + case sendMessage = "store" + } + + // MARK: Key Set + public struct KeySet : Hashable { + public let ed25519Key: String + public let x25519Key: String + + public static func == (lhs: KeySet, rhs: KeySet) -> Bool { + return lhs.ed25519Key == rhs.ed25519Key && lhs.x25519Key == rhs.x25519Key + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ed25519Key) + hasher.combine(x25519Key) + } + } + + // MARK: Initialization + internal init(address: String, port: UInt16, publicKeySet: KeySet) { + self.address = address + self.port = port + self.publicKeySet = publicKeySet + } + + // MARK: Equality + public static func == (lhs: Snode, rhs: Snode) -> Bool { + return lhs.address == rhs.address && lhs.port == rhs.port && lhs.publicKeySet == rhs.publicKeySet + } + + // MARK: Hashing + public func hash(into hasher: inout Hasher) { + hasher.combine(address) + hasher.combine(port) + publicKeySet.hash(into: &hasher) + } + + // MARK: Description + public var description: String { "\(address):\(port)" } +} diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift new file mode 100644 index 000000000..d8a94df3c --- /dev/null +++ b/SessionSnodeKit/SnodeAPI.swift @@ -0,0 +1,316 @@ +import PromiseKit + +public enum SnodeAPI { + + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + internal static var snodeFailureCount: [Snode:UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + internal static var snodePool: Set = [] // TODO: Just get/set the database values directly? + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + internal static var swarmCache: [String:Set] = [:] // TODO: Just get/set the database values directly? + + // MARK: Settings + private static let maxRetryCount: UInt = 4 + private static let minimumSnodePoolCount = 64 + private static let minimumSwarmSnodeCount = 2 + private static let seedNodePool: Set = [ "https://storage.seed1.loki.network", "https://storage.seed3.loki.network", "https://public.loki.foundation" ] + private static let snodeFailureThreshold = 4 + private static let targetSwarmSnodeCount = 2 + + internal static var powDifficulty: UInt = 1 + /// - Note: Changing this on the fly is not recommended. + internal static var useOnionRequests = true + + // MARK: Error + public enum Error : LocalizedError { + case clockOutOfSync + case randomSnodePoolUpdatingFailed + + public var errorDescription: String? { + switch self { + case .clockOutOfSync: return "Your clock is out of sync with the service node network." + case .randomSnodePoolUpdatingFailed: return "Failed to update random service node pool." + } + } + } + + // MARK: Type Aliases + public typealias MessageListPromise = Promise<[JSON]> + public typealias RawResponse = Any + public typealias RawResponsePromise = Promise + + // MARK: Core + internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String, parameters: JSON) -> RawResponsePromise { + if useOnionRequests { + return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } + } else { + let url = "\(snode.address):\(snode.port)/storage_rpc/v1" + return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in + guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error + } + } + } + + internal static func getRandomSnode() -> Promise { + if snodePool.count < minimumSnodePoolCount { + snodePool = Configuration.shared.storage.getSnodePool() + } + if snodePool.count < minimumSnodePoolCount { + let target = seedNodePool.randomElement()! + let url = "\(target)/json_rpc" + let parameters: JSON = [ + "method" : "get_n_service_nodes", + "params" : [ + "active_only" : true, + "fields" : [ + "public_ip" : true, "storage_port" : true, "pubkey_ed25519" : true, "pubkey_x25519" : true + ] + ] + ] + SNLog("Populating snode pool using: \(target).") + let (promise, seal) = Promise.pending() + attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { + HTTP.execute(.post, url, parameters: parameters, useSeedNodeURLSession: true).map2 { json -> Snode in + guard let intermediate = json["result"] as? JSON, let rawSnodes = intermediate["service_node_states"] as? [JSON] else { throw Error.randomSnodePoolUpdatingFailed } + snodePool = Set(rawSnodes.compactMap { rawSnode in + guard let address = rawSnode["public_ip"] as? String, let port = rawSnode["storage_port"] as? Int, + let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { + SNLog("Failed to parse target from: \(rawSnode).") + return nil + } + return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + }) + // randomElement() uses the system's default random generator, which is cryptographically secure + if !snodePool.isEmpty { + return snodePool.randomElement()! + } else { + throw Error.randomSnodePoolUpdatingFailed + } + } + }.done2 { snode in + seal.fulfill(snode) + Configuration.shared.storage.with { transaction in + SNLog("Persisting snode pool to database.") + Configuration.shared.storage.setSnodePool(to: SnodeAPI.snodePool, using: transaction) + } + }.catch2 { error in + SNLog("Failed to contact seed node at: \(target).") + seal.reject(error) + } + return promise + } else { + return Promise { seal in + // randomElement() uses the system's default random generator, which is cryptographically secure + seal.fulfill(snodePool.randomElement()!) + } + } + } + + internal static func getSwarm(for publicKey: String, isForcedReload: Bool = false) -> Promise> { + if swarmCache[publicKey] == nil { + swarmCache[publicKey] = Configuration.shared.storage.getSwarm(for: publicKey) + } + if let cachedSwarm = swarmCache[publicKey], cachedSwarm.count >= minimumSwarmSnodeCount && !isForcedReload { + return Promise> { $0.fulfill(cachedSwarm) } + } else { + SNLog("Getting swarm for: \((publicKey == Configuration.shared.storage.getUserPublicKey()) ? "self" : publicKey).") + let parameters: [String:Any] = [ "pubKey" : publicKey ] + return getRandomSnode().then2 { snode in + attempt(maxRetryCount: 4, recoveringOn: Threading.workQueue) { + invoke(.getSwarm, on: snode, associatedWith: publicKey, parameters: parameters) + } + }.map2 { rawSnodes in + let swarm = parseSnodes(from: rawSnodes) + swarmCache[publicKey] = swarm + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.setSwarm(to: swarm, for: publicKey, using: transaction) + } + return swarm + } + } + } + + internal static func getTargetSnodes(for publicKey: String) -> Promise<[Snode]> { + // shuffled() uses the system's default random generator, which is cryptographically secure + return getSwarm(for: publicKey).map2 { Array($0.shuffled().prefix(targetSwarmSnodeCount)) } + } + + internal static func dropSnodeFromSnodePool(_ snode: Snode) { + var snodePool = SnodeAPI.snodePool + snodePool.remove(snode) + SnodeAPI.snodePool = snodePool + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.setSnodePool(to: snodePool, using: transaction) + } + } + + public static func clearSnodePool() { + snodePool.removeAll() + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.setSnodePool(to: [], using: transaction) + } + } + + internal static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) { + let swarm = SnodeAPI.swarmCache[publicKey] + if var swarm = swarm, let index = swarm.firstIndex(of: snode) { + swarm.remove(at: index) + SnodeAPI.swarmCache[publicKey] = swarm + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.setSwarm(to: swarm, for: publicKey, using: transaction) + } + } + } + + // MARK: Receiving + public static func getMessages(for publicKey: String) -> Promise> { + let (promise, seal) = Promise>.pending() + Threading.workQueue.async { + attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + getTargetSnodes(for: publicKey).mapValues2 { targetSnode in + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.pruneLastMessageHashInfoIfExpired(for: targetSnode, associatedWith: publicKey, using: transaction) + } + let lastHash = Configuration.shared.storage.getLastMessageHash(for: targetSnode, associatedWith: publicKey) ?? "" + let parameters = [ "pubKey" : publicKey, "lastHash" : lastHash ] + return invoke(.getMessages, on: targetSnode, associatedWith: publicKey, parameters: parameters).map2 { rawResponse in + parseRawMessagesResponse(rawResponse, from: targetSnode, associatedWith: publicKey) + } + }.map2 { Set($0) } + }.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } + } + return promise + } + + // MARK: Sending + public static func sendMessage(_ message: Message) -> Promise> { + let (promise, seal) = Promise>.pending() + let publicKey = message.recipientPublicKey + Threading.workQueue.async { + getTargetSnodes(for: publicKey).map2 { targetSnodes in + let parameters = message.toJSON() + return Set(targetSnodes.map { targetSnode in + let result = attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) + } + result.done2 { rawResponse in + if let json = rawResponse as? JSON, let powDifficulty = json["difficulty"] as? Int { + guard powDifficulty != SnodeAPI.powDifficulty, powDifficulty < 100 else { return } + SNLog("Setting proof of work difficulty to \(powDifficulty).") + SnodeAPI.powDifficulty = UInt(powDifficulty) + } else { + SNLog("Failed to update proof of work difficulty from: \(rawResponse).") + } + } + return result + }) + }.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } + } + return promise + } + + // MARK: Parsing + + // The parsing utilities below use a best attempt approach to parsing; they warn for parsing failures but don't throw exceptions. + + private static func parseSnodes(from rawResponse: Any) -> Set { + guard let json = rawResponse as? JSON, let rawSnodes = json["snodes"] as? [JSON] else { + SNLog("Failed to parse targets from: \(rawResponse).") + return [] + } + return Set(rawSnodes.compactMap { rawSnode in + guard let address = rawSnode["ip"] as? String, let portAsString = rawSnode["port"] as? String, let port = UInt16(portAsString), let ed25519PublicKey = rawSnode["pubkey_ed25519"] as? String, let x25519PublicKey = rawSnode["pubkey_x25519"] as? String, address != "0.0.0.0" else { + SNLog("Failed to parse target from: \(rawSnode).") + return nil + } + return Snode(address: "https://\(address)", port: port, publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) + }) + } + + internal static func parseRawMessagesResponse(_ rawResponse: Any, from snode: Snode, associatedWith publicKey: String) -> [JSON] { + guard let json = rawResponse as? JSON, let rawMessages = json["messages"] as? [JSON] else { return [] } + updateLastMessageHashValueIfPossible(for: snode, associatedWith: publicKey, from: rawMessages) + return removeDuplicates(from: rawMessages, associatedWith: publicKey) + } + + private static func updateLastMessageHashValueIfPossible(for snode: Snode, associatedWith publicKey: String, from rawMessages: [JSON]) { + if let lastMessage = rawMessages.last, let lastHash = lastMessage["hash"] as? String, let expirationDate = lastMessage["expiration"] as? UInt64 { + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.setLastMessageHashInfo(for: snode, associatedWith: publicKey, + to: [ "hash" : lastHash, "expirationDate" : NSNumber(value: expirationDate) ], using: transaction) + } + } else if (!rawMessages.isEmpty) { + SNLog("Failed to update last message hash value from: \(rawMessages).") + } + } + + private static func removeDuplicates(from rawMessages: [JSON], associatedWith publicKey: String) -> [JSON] { + var receivedMessages = Configuration.shared.storage.getReceivedMessages(for: publicKey) + return rawMessages.filter { rawMessage in + guard let hash = rawMessage["hash"] as? String else { + SNLog("Missing hash value for message: \(rawMessage).") + return false + } + let isDuplicate = receivedMessages.contains(hash) + receivedMessages.insert(hash) + Configuration.shared.storage.with { transaction in + Configuration.shared.storage.setReceivedMessages(to: receivedMessages, for: publicKey, using: transaction) + } + return !isDuplicate + } + } + + // MARK: Error Handling + /// - Note: Should only be invoked from `Threading.workQueue` to avoid race conditions. + @discardableResult + internal static func handleError(withStatusCode statusCode: UInt, json: JSON?, forSnode snode: Snode, associatedWith publicKey: String? = nil) -> Error? { + func handleBadSnode() { + let oldFailureCount = SnodeAPI.snodeFailureCount[snode] ?? 0 + let newFailureCount = oldFailureCount + 1 + SnodeAPI.snodeFailureCount[snode] = newFailureCount + SNLog("Couldn't reach snode at: \(snode); setting failure count to \(newFailureCount).") + if newFailureCount >= SnodeAPI.snodeFailureThreshold { + SNLog("Failure threshold reached for: \(snode); dropping it.") + if let publicKey = publicKey { + SnodeAPI.dropSnodeFromSwarmIfNeeded(snode, publicKey: publicKey) + } + SnodeAPI.dropSnodeFromSnodePool(snode) + SNLog("Snode pool count: \(snodePool.count).") + SnodeAPI.snodeFailureCount[snode] = 0 + } + } + switch statusCode { + case 0, 400, 500, 503: + // The snode is unreachable + handleBadSnode() + case 406: + SNLog("The user's clock is out of sync with the service node network.") + return Error.clockOutOfSync + case 421: + // The snode isn't associated with the given public key anymore + if let publicKey = publicKey { + SNLog("Invalidating swarm for: \(publicKey).") + SnodeAPI.dropSnodeFromSwarmIfNeeded(snode, publicKey: publicKey) + } else { + SNLog("Got a 421 without an associated public key.") + } + case 432: + // The proof of work difficulty is too low + if let powDifficulty = json?["difficulty"] as? UInt { + if powDifficulty < 100 { + SNLog("Setting proof of work difficulty to \(powDifficulty).") + SnodeAPI.powDifficulty = UInt(powDifficulty) + } else { + handleBadSnode() + } + } else { + SNLog("Failed to update proof of work difficulty.") + } + default: + handleBadSnode() + SNLog("Unhandled response code: \(statusCode).") + } + return nil + } +} diff --git a/SessionSnodeKit/Storage.swift b/SessionSnodeKit/Storage.swift new file mode 100644 index 000000000..6f19593e6 --- /dev/null +++ b/SessionSnodeKit/Storage.swift @@ -0,0 +1,18 @@ + +public protocol Storage { + + func with(_ work: (Any) -> Void) + + func getUserPublicKey() -> String? + func getOnionRequestPaths() -> [OnionRequestAPI.Path] + func setOnionRequestPaths(to paths: [OnionRequestAPI.Path], using transaction: Any) + func getSnodePool() -> Set + func setSnodePool(to snodePool: Set, using transaction: Any) + func getSwarm(for publicKey: String) -> Set + func setSwarm(to swarm: Set, for publicKey: String, using transaction: Any) + func getLastMessageHash(for snode: Snode, associatedWith publicKey: String) -> String? + func setLastMessageHashInfo(for snode: Snode, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) + func pruneLastMessageHashInfoIfExpired(for snode: Snode, associatedWith publicKey: String, using transaction: Any) + func getReceivedMessages(for publicKey: String) -> Set + func setReceivedMessages(to receivedMessages: Set, for publicKey: String, using transaction: Any) +} diff --git a/SessionSnodeKit/Utilities/AESGCM.swift b/SessionSnodeKit/Utilities/AESGCM.swift new file mode 100644 index 000000000..a5359a308 --- /dev/null +++ b/SessionSnodeKit/Utilities/AESGCM.swift @@ -0,0 +1,69 @@ +import CryptoSwift +import Curve25519Kit + +internal enum AESGCM { + internal static let gcmTagSize: UInt = 16 + internal static let ivSize: UInt = 12 + + internal struct EncryptionResult { internal let ciphertext: Data, symmetricKey: Data, ephemeralPublicKey: Data } + + internal enum Error : LocalizedError { + case keyPairGenerationFailed + case sharedSecretGenerationFailed + + public var errorDescription: String? { + switch self { + case .keyPairGenerationFailed: return "Couldn't generate a key pair." + case .sharedSecretGenerationFailed: return "Couldn't generate a shared secret." + } + } + } + + /// - Note: Sync. Don't call from the main thread. + internal static func decrypt(_ ivAndCiphertext: Data, with symmetricKey: Data) throws -> Data { + if Thread.isMainThread { + #if DEBUG + preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") + #endif + } + let iv = ivAndCiphertext[0.. Data { + if Thread.isMainThread { + #if DEBUG + preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") + #endif + } + let iv = Data.getSecureRandomData(ofSize: ivSize)! + let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagSize), mode: .combined) + let aes = try AES(key: symmetricKey.bytes, blockMode: gcm, padding: .noPadding) + let ciphertext = try aes.encrypt(plaintext.bytes) + return iv + Data(ciphertext) + } + + /// - Note: Sync. Don't call from the main thread. + internal static func encrypt(_ plaintext: Data, for hexEncodedX25519PublicKey: String) throws -> EncryptionResult { + if Thread.isMainThread { + #if DEBUG + preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") + #endif + } + let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey) + guard let ephemeralKeyPair = Curve25519.generateKeyPair() else { + throw Error.keyPairGenerationFailed + } + guard let ephemeralSharedSecret = Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, andKeyPair: ephemeralKeyPair) else { + throw Error.sharedSecretGenerationFailed + } + let salt = "LOKI" + let symmetricKey = try HMAC(key: salt.bytes, variant: .sha256).authenticate(ephemeralSharedSecret.bytes) + let ciphertext = try encrypt(plaintext, with: Data(symmetricKey)) + return EncryptionResult(ciphertext: ciphertext, symmetricKey: Data(symmetricKey), ephemeralPublicKey: ephemeralKeyPair.publicKey()) + } +} diff --git a/SessionSnodeKit/Utilities/Array+Utilities.swift b/SessionSnodeKit/Utilities/Array+Utilities.swift new file mode 100644 index 000000000..0fd78ba49 --- /dev/null +++ b/SessionSnodeKit/Utilities/Array+Utilities.swift @@ -0,0 +1,7 @@ + +internal extension Array where Element : CustomStringConvertible { + + var prettifiedDescription: String { + return "[ " + map { $0.description }.joined(separator: ", ") + " ]" + } +} diff --git a/SessionSnodeKit/Utilities/Data+Utilities.swift b/SessionSnodeKit/Utilities/Data+Utilities.swift new file mode 100644 index 000000000..565ead5ce --- /dev/null +++ b/SessionSnodeKit/Utilities/Data+Utilities.swift @@ -0,0 +1,32 @@ +import Foundation + +internal extension Data { + + /// Returns `size` bytes of random data generated using the default secure random number generator. See + /// [SecRandomCopyBytes](https://developer.apple.com/documentation/security/1399291-secrandomcopybytes) for more information. + static func getSecureRandomData(ofSize size: UInt) -> Data? { + var data = Data(count: Int(size)) + let result = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, Int(size), $0.baseAddress!) } + guard result == errSecSuccess else { return nil } + return data + } + + init(from inputStream: InputStream) throws { + self.init() + inputStream.open() + defer { inputStream.close() } + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + while inputStream.hasBytesAvailable { + let count = inputStream.read(buffer, maxLength: bufferSize) + if count < 0 { + throw inputStream.streamError! + } else if count == 0 { + break + } else { + append(buffer, count: count) + } + } + } +} diff --git a/SessionSnodeKit/Utilities/Dictionary+Utilities.swift b/SessionSnodeKit/Utilities/Dictionary+Utilities.swift new file mode 100644 index 000000000..18c407b4d --- /dev/null +++ b/SessionSnodeKit/Utilities/Dictionary+Utilities.swift @@ -0,0 +1,13 @@ + +internal extension Dictionary { + + var prettifiedDescription: String { + return "[ " + map { key, value in + let keyDescription = String(describing: key) + let valueDescription = String(describing: value) + let maxLength = 20 + let truncatedValueDescription = valueDescription.count > maxLength ? valueDescription.prefix(maxLength) + "..." : valueDescription + return keyDescription + " : " + truncatedValueDescription + }.joined(separator: ", ") + " ]" + } +} diff --git a/SessionSnodeKit/Utilities/JSON.swift b/SessionSnodeKit/Utilities/JSON.swift new file mode 100644 index 000000000..7335b1e3d --- /dev/null +++ b/SessionSnodeKit/Utilities/JSON.swift @@ -0,0 +1,2 @@ + +public typealias JSON = [String:Any] diff --git a/SessionSnodeKit/Utilities/Logging.swift b/SessionSnodeKit/Utilities/Logging.swift new file mode 100644 index 000000000..20d7d9d58 --- /dev/null +++ b/SessionSnodeKit/Utilities/Logging.swift @@ -0,0 +1,6 @@ + +internal func SNLog(_ message: String) { + #if DEBUG + print("[Session] \(message)") + #endif +} diff --git a/SessionSnodeKit/Utilities/Promise+Delaying.swift b/SessionSnodeKit/Utilities/Promise+Delaying.swift new file mode 100644 index 000000000..9bb1ee1d2 --- /dev/null +++ b/SessionSnodeKit/Utilities/Promise+Delaying.swift @@ -0,0 +1,13 @@ +import PromiseKit + +/// Delay the execution of the promise constructed in `body` by `delay` seconds. +internal func withDelay(_ delay: TimeInterval, completionQueue: DispatchQueue, body: @escaping () -> Promise) -> Promise { + #if DEBUG + assert(Thread.current.isMainThread) // Timers don't do well on background queues + #endif + let (promise, seal) = Promise.pending() + Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in + body().done(on: completionQueue) { seal.fulfill($0) }.catch(on: completionQueue) { seal.reject($0) } + } + return promise +} diff --git a/SessionSnodeKit/Utilities/Promise+Hashing.swift b/SessionSnodeKit/Utilities/Promise+Hashing.swift new file mode 100644 index 000000000..47bb42c62 --- /dev/null +++ b/SessionSnodeKit/Utilities/Promise+Hashing.swift @@ -0,0 +1,13 @@ +import PromiseKit + +extension Promise : Hashable { + + public func hash(into hasher: inout Hasher) { + let reference = ObjectIdentifier(self) + hasher.combine(reference.hashValue) + } + + public static func == (lhs: Promise, rhs: Promise) -> Bool { + return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } +} diff --git a/SessionSnodeKit/Utilities/Promise+Retrying.swift b/SessionSnodeKit/Utilities/Promise+Retrying.swift new file mode 100644 index 000000000..a386b23fd --- /dev/null +++ b/SessionSnodeKit/Utilities/Promise+Retrying.swift @@ -0,0 +1,14 @@ +import PromiseKit + +/// Retry the promise constructed in `body` up to `maxRetryCount` times. +internal func attempt(maxRetryCount: UInt, recoveringOn queue: DispatchQueue, body: @escaping () -> Promise) -> Promise { + var retryCount = 0 + func attempt() -> Promise { + return body().recover(on: queue) { error -> Promise in + guard retryCount < maxRetryCount else { throw error } + retryCount += 1 + return attempt() + } + } + return attempt() +} diff --git a/SessionSnodeKit/Utilities/Promise+Threading.swift b/SessionSnodeKit/Utilities/Promise+Threading.swift new file mode 100644 index 000000000..7b4936794 --- /dev/null +++ b/SessionSnodeKit/Utilities/Promise+Threading.swift @@ -0,0 +1,91 @@ +import PromiseKit + +internal extension Thenable { + + @discardableResult + func then2(_ body: @escaping (T) throws -> U) -> Promise where U : Thenable { + return then(on: Threading.workQueue, body) + } + + @discardableResult + func map2(_ transform: @escaping (T) throws -> U) -> Promise { + return map(on: Threading.workQueue, transform) + } + + @discardableResult + func done2(_ body: @escaping (T) throws -> Void) -> Promise { + return done(on: Threading.workQueue, body) + } + + @discardableResult + func get2(_ body: @escaping (T) throws -> Void) -> Promise { + return get(on: Threading.workQueue, body) + } +} + +internal extension Thenable where T: Sequence { + + @discardableResult + func mapValues2(_ transform: @escaping (T.Iterator.Element) throws -> U) -> Promise<[U]> { + return mapValues(on: Threading.workQueue, transform) + } +} + +internal extension Guarantee { + + @discardableResult + func then2(_ body: @escaping (T) -> Guarantee) -> Guarantee { + return then(on: Threading.workQueue, body) + } + + @discardableResult + func map2(_ body: @escaping (T) -> U) -> Guarantee { + return map(on: Threading.workQueue, body) + } + + @discardableResult + func done2(_ body: @escaping (T) -> Void) -> Guarantee { + return done(on: Threading.workQueue, body) + } + + @discardableResult + func get2(_ body: @escaping (T) -> Void) -> Guarantee { + return get(on: Threading.workQueue, body) + } +} + +internal extension CatchMixin { + + @discardableResult + func catch2(_ body: @escaping (Error) -> Void) -> PMKFinalizer { + return self.catch(on: Threading.workQueue, body) + } + + @discardableResult + func recover2(_ body: @escaping(Error) throws -> U) -> Promise where U.T == T { + return recover(on: Threading.workQueue, body) + } + + @discardableResult + func recover2(_ body: @escaping(Error) -> Guarantee) -> Guarantee { + return recover(on: Threading.workQueue, body) + } + + @discardableResult + func ensure2(_ body: @escaping () -> Void) -> Promise { + return ensure(on: Threading.workQueue, body) + } +} + +internal extension CatchMixin where T == Void { + + @discardableResult + func recover2(_ body: @escaping(Error) -> Void) -> Guarantee { + return recover(on: Threading.workQueue, body) + } + + @discardableResult + func recover2(_ body: @escaping(Error) throws -> Void) -> Promise { + return recover(on: Threading.workQueue, body) + } +} diff --git a/SessionSnodeKit/Utilities/String+Utilities.swift b/SessionSnodeKit/Utilities/String+Utilities.swift new file mode 100644 index 000000000..6d412b450 --- /dev/null +++ b/SessionSnodeKit/Utilities/String+Utilities.swift @@ -0,0 +1,9 @@ +import Foundation + +internal extension String { + + func removingPrefix(_ prefix: String) -> String { + guard let range = self.range(of: prefix), range.lowerBound == startIndex else { return self } + return String(self[range.upperBound.. /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + B19B891E99B1507CAC8AAD19 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SessionSnodeKit-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; B4E9B04E862FB64FC9A8F79B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -3776,6 +3962,34 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C3C2A59B255385C100C340D1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, + C3C2A5BF255385EE00C340D1 /* Message.swift in Sources */, + C3C2A5DA2553860B00C340D1 /* Logging.swift in Sources */, + C3C2A5E22553860B00C340D1 /* Promise+Retrying.swift in Sources */, + C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, + C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, + C3C2A5E12553860B00C340D1 /* Dictionary+Utilities.swift in Sources */, + C3C2A5C5255385EE00C340D1 /* HTTP.swift in Sources */, + C3C2A5C6255385EE00C340D1 /* Notification+Session.swift in Sources */, + C3C2A5DF2553860B00C340D1 /* Promise+Delaying.swift in Sources */, + C3C2A5DC2553860B00C340D1 /* Promise+Threading.swift in Sources */, + C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */, + C3C2A5DD2553860B00C340D1 /* Array+Utilities.swift in Sources */, + C3C2A5E32553860B00C340D1 /* AESGCM.swift in Sources */, + C3C2A5DE2553860B00C340D1 /* String+Utilities.swift in Sources */, + C3C2A5E52553860B00C340D1 /* JSON.swift in Sources */, + C3C2A5DB2553860B00C340D1 /* Promise+Hashing.swift in Sources */, + C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, + C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, + C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, + C3C2A5C1255385EE00C340D1 /* Storage.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D221A085169C9E5E00537ABF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -4102,6 +4316,11 @@ target = 453518911FC63DBF00210559 /* SignalMessaging */; targetProxy = C36B8705243C50B00049991D /* PBXContainerItemProxy */; }; + C3C2A5A5255385C100C340D1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C3C2A59E255385C100C340D1 /* SessionSnodeKit */; + targetProxy = C3C2A5A4255385C100C340D1 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -4499,6 +4718,132 @@ }; name = "App Store Release"; }; + C3C2A5A8255385C100C340D1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A6344D429FFAC3B44E6A06FA /* Pods-SessionSnodeKit.debug.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = SUQ8J2PCT7; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = SessionSnodeKit/Meta/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionSnodeKit"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + C3C2A5A9255385C100C340D1 /* App Store Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C022DD8E076866C6241610BF /* Pods-SessionSnodeKit.app store release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = SUQ8J2PCT7; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = SessionSnodeKit/Meta/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionSnodeKit"; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = "App Store Release"; + }; D221A0BA169C9E5F00537ABF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4933,6 +5278,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = "App Store Release"; }; + C3C2A5AA255385C100C340D1 /* Build configuration list for PBXNativeTarget "SessionSnodeKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C3C2A5A8255385C100C340D1 /* Debug */, + C3C2A5A9255385C100C340D1 /* App Store Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "App Store Release"; + }; D221A083169C9E5E00537ABF /* Build configuration list for PBXProject "Signal" */ = { isa = XCConfigurationList; buildConfigurations = (