Browse Source

Create SessionSnodeKit

pull/308/head
nielsandriesse 2 years ago
parent
commit
2b1e322832
  1. 15
      Podfile
  2. 8
      Podfile.lock
  3. 2
      Pods
  4. 13
      SessionSnodeKit/Configuration.swift
  5. 108
      SessionSnodeKit/HTTP.swift
  6. 25
      SessionSnodeKit/Message.swift
  7. 22
      SessionSnodeKit/Meta/Info.plist
  8. 4
      SessionSnodeKit/Meta/SessionSnodeKit.h
  9. 8
      SessionSnodeKit/Notification+Session.swift
  10. 72
      SessionSnodeKit/OnionRequestAPI+Encryption.swift
  11. 427
      SessionSnodeKit/OnionRequestAPI.swift
  12. 55
      SessionSnodeKit/Snode.swift
  13. 316
      SessionSnodeKit/SnodeAPI.swift
  14. 18
      SessionSnodeKit/Storage.swift
  15. 69
      SessionSnodeKit/Utilities/AESGCM.swift
  16. 7
      SessionSnodeKit/Utilities/Array+Utilities.swift
  17. 32
      SessionSnodeKit/Utilities/Data+Utilities.swift
  18. 13
      SessionSnodeKit/Utilities/Dictionary+Utilities.swift
  19. 2
      SessionSnodeKit/Utilities/JSON.swift
  20. 6
      SessionSnodeKit/Utilities/Logging.swift
  21. 13
      SessionSnodeKit/Utilities/Promise+Delaying.swift
  22. 13
      SessionSnodeKit/Utilities/Promise+Hashing.swift
  23. 14
      SessionSnodeKit/Utilities/Promise+Retrying.swift
  24. 91
      SessionSnodeKit/Utilities/Promise+Threading.swift
  25. 9
      SessionSnodeKit/Utilities/String+Utilities.swift
  26. 6
      SessionSnodeKit/Utilities/Threading.swift
  27. 354
      Signal.xcodeproj/project.pbxproj

15
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

8
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

2
Pods

@ -1 +1 @@
Subproject commit d1b2c2c2fe1b47ab1314192e9320f6cbc30871be
Subproject commit 5ba5f8d5a001bbf4d925ef0f246bf402e03b097d

13
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
}
}

108
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<JSON> {
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<JSON> {
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<JSON> {
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<JSON>.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
}
}

25
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
}
}

22
SessionSnodeKit/Meta/Info.plist

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

4
SessionSnodeKit/Meta/SessionSnodeKit.h

@ -0,0 +1,4 @@
#import <Foundation/Foundation.h>
FOUNDATION_EXPORT double SessionSnodeKitVersionNumber;
FOUNDATION_EXPORT const unsigned char SessionSnodeKitVersionString[];

8
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")
}

72
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<Int32>.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<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.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<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.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
}
}

427
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<Snode> = []
// 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<Void> {
let (promise, seal) = Promise<Void>.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<Set<Snode>> {
if guardSnodes.count >= targetGuardSnodeCount {
return Promise<Set<Snode>> { $0.fulfill(guardSnodes) }
} else {
SNLog("Populating guard snode cache.")
return SnodeAPI.getRandomSnode().then2 { _ -> Promise<Set<Snode>> 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<Snode> {
// randomElement() uses the system's default random generator, which is cryptographically secure
guard let candidate = unusedSnodes.randomElement() else { return Promise<Snode> { $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<Path> {
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<OnionBuildingResult> {
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<AESGCM.EncryptionResult> in
guardSnode = path.first!
// Encrypt in reverse order, i.e. the destination first
return encrypt(payload, for: destination).then2 { r -> Promise<AESGCM.EncryptionResult> 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<AESGCM.EncryptionResult> {
if path.isEmpty {
return Promise<AESGCM.EncryptionResult> { $0.fulfill(encryptionResult) }
} else {
let lhs = Destination.snode(path.removeLast())
return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise<AESGCM.EncryptionResult> 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<JSON> {
let payload: JSON = [ "method" : method.rawValue, "params" : parameters ]
return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise<JSON> 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<JSON> {
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..<url.endIndex])
}
let parametersAsString: String
headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"]
if let parametersAsInputStream = request.httpBodyStream, let parameters = try? Data(from: parametersAsInputStream) {
parametersAsString = "{ \"fileUpload\" : \"\(String(data: parameters.base64EncodedData(), encoding: .utf8) ?? "null")\" }"
} else {
parametersAsString = "null"
}
let payload: JSON = [
"body" : parametersAsString,
"endpoint" : endpoint,
"method" : request.httpMethod!,
"headers" : headers
]
let destination = Destination.server(host: host, x25519PublicKey: x25519PublicKey)
let promise = sendOnionRequest(with: payload, to: destination, isJSONRequired: isJSONRequired)
promise.catch2 { error in
SNLog("Couldn't reach server: \(url) due to error: \(error).")
}
return promise
}
internal static func sendOnionRequest(with payload: JSON, to destination: Destination, isJSONRequired: Bool = true) -> Promise<JSON> {
let (promise, seal) = Promise<JSON>.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)..<message.endIndex]
if let path = path, let snode = path.first(where: { $0.publicKeySet.ed25519Key == ed25519PublicKey }) {
var snodeFailureCount = OnionRequestAPI.snodeFailureCount[snode] ?? 0
snodeFailureCount += 1
if snodeFailureCount >= 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
}
}

55
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)" }
}

316
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<Snode> = [] // 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<Snode>] = [:] // 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<String> = [ "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<RawResponse>
// 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<Any> 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<Snode> {
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<Snode>.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<Snode> { 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<Set<Snode>> {
if swarmCache[publicKey] == nil {
swarmCache[publicKey] = Configuration.shared.storage.getSwarm(for: publicKey)
}
if let cachedSwarm = swarmCache[publicKey], cachedSwarm.count >= minimumSwarmSnodeCount && !isForcedReload {
return Promise<Set<Snode>> { $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<Set<MessageListPromise>> {
let (promise, seal) = Promise<Set<MessageListPromise>>.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<Set<RawResponsePromise>> {
let (promise, seal) = Promise<Set<RawResponsePromise>>.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<Snode> {
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).")