mirror of https://github.com/oxen-io/session-ios
Create SessionSnodeKit
parent
a46630c192
commit
2b1e322832
@ -1 +1 @@
|
||||
Subproject commit d1b2c2c2fe1b47ab1314192e9320f6cbc30871be
|
||||
Subproject commit 5ba5f8d5a001bbf4d925ef0f246bf402e03b097d
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,4 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
FOUNDATION_EXPORT double SessionSnodeKitVersionNumber;
|
||||
FOUNDATION_EXPORT const unsigned char SessionSnodeKitVersionString[];
|
@ -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")
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)" }
|
||||
}
|
@ -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).")
|
||||
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
|
||||
}
|
||||
}
|
@ -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<Snode>
|
||||
func setSnodePool(to snodePool: Set<Snode>, using transaction: Any)
|
||||
func getSwarm(for publicKey: String) -> Set<Snode>
|
||||
func setSwarm(to swarm: Set<Snode>, 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<String>
|
||||
func setReceivedMessages(to receivedMessages: Set<String>, for publicKey: String, using transaction: Any)
|
||||
}
|
@ -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..<Int(ivSize)]
|
||||
let ciphertext = ivAndCiphertext[Int(ivSize)...]
|
||||
let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagSize), mode: .combined)
|
||||
let aes = try AES(key: symmetricKey.bytes, blockMode: gcm, padding: .noPadding)
|
||||
return Data(try aes.decrypt(ciphertext.bytes))
|
||||
}
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
internal static func encrypt(_ plaintext: Data, with symmetricKey: Data) throws -> 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())
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
|
||||
internal extension Array where Element : CustomStringConvertible {
|
||||
|
||||
var prettifiedDescription: String {
|
||||
return "[ " + map { $0.description }.joined(separator: ", ") + " ]"
|
||||
}
|
||||
}
|
@ -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<UInt8>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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: ", ") + " ]"
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
|
||||
public typealias JSON = [String:Any]
|
@ -0,0 +1,6 @@
|
||||
|
||||
internal func SNLog(_ message: String) {
|
||||
#if DEBUG
|
||||
print("[Session] \(message)")
|
||||
#endif
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import PromiseKit
|
||||
|
||||
/// Delay the execution of the promise constructed in `body` by `delay` seconds.
|
||||
internal func withDelay<T>(_ delay: TimeInterval, completionQueue: DispatchQueue, body: @escaping () -> Promise<T>) -> Promise<T> {
|
||||
#if DEBUG
|
||||
assert(Thread.current.isMainThread) // Timers don't do well on background queues
|
||||
#endif
|
||||
let (promise, seal) = Promise<T>.pending()
|
||||
Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
|
||||
body().done(on: completionQueue) { seal.fulfill($0) }.catch(on: completionQueue) { seal.reject($0) }
|
||||
}
|
||||
return promise
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import PromiseKit
|
||||
|
||||
/// Retry the promise constructed in `body` up to `maxRetryCount` times.
|
||||
internal func attempt<T>(maxRetryCount: UInt, recoveringOn queue: DispatchQueue, body: @escaping () -> Promise<T>) -> Promise<T> {
|
||||
var retryCount = 0
|
||||
func attempt() -> Promise<T> {
|
||||
return body().recover(on: queue) { error -> Promise<T> in
|
||||
guard retryCount < maxRetryCount else { throw error }
|
||||
retryCount += 1
|
||||
return attempt()
|
||||
}
|
||||
}
|
||||
return attempt()
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import PromiseKit
|
||||
|
||||
internal extension Thenable {
|
||||
|
||||
@discardableResult
|
||||
func then2<U>(_ body: @escaping (T) throws -> U) -> Promise<U.T> where U : Thenable {
|
||||
return then(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func map2<U>(_ transform: @escaping (T) throws -> U) -> Promise<U> {
|
||||
return map(on: Threading.workQueue, transform)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func done2(_ body: @escaping (T) throws -> Void) -> Promise<Void> {
|
||||
return done(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func get2(_ body: @escaping (T) throws -> Void) -> Promise<T> {
|
||||
return get(on: Threading.workQueue, body)
|
||||
}
|
||||
}
|
||||
|
||||
internal extension Thenable where T: Sequence {
|
||||
|
||||
@discardableResult
|
||||
func mapValues2<U>(_ transform: @escaping (T.Iterator.Element) throws -> U) -> Promise<[U]> {
|
||||
return mapValues(on: Threading.workQueue, transform)
|
||||
}
|
||||
}
|
||||
|
||||
internal extension Guarantee {
|
||||
|
||||
@discardableResult
|
||||
func then2<U>(_ body: @escaping (T) -> Guarantee<U>) -> Guarantee<U> {
|
||||
return then(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func map2<U>(_ body: @escaping (T) -> U) -> Guarantee<U> {
|
||||
return map(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func done2(_ body: @escaping (T) -> Void) -> Guarantee<Void> {
|
||||
return done(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func get2(_ body: @escaping (T) -> Void) -> Guarantee<T> {
|
||||
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<U: Thenable>(_ body: @escaping(Error) throws -> U) -> Promise<T> where U.T == T {
|
||||
return recover(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recover2(_ body: @escaping(Error) -> Guarantee<T>) -> Guarantee<T> {
|
||||
return recover(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensure2(_ body: @escaping () -> Void) -> Promise<T> {
|
||||
return ensure(on: Threading.workQueue, body)
|
||||
}
|
||||
}
|
||||
|
||||
internal extension CatchMixin where T == Void {
|
||||
|
||||
@discardableResult
|
||||
func recover2(_ body: @escaping(Error) -> Void) -> Guarantee<Void> {
|
||||
return recover(on: Threading.workQueue, body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recover2(_ body: @escaping(Error) throws -> Void) -> Promise<Void> {
|
||||
return recover(on: Threading.workQueue, body)
|
||||
}
|
||||
}
|
@ -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..<endIndex])
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
internal enum Threading {
|
||||
|
||||
internal static let workQueue = DispatchQueue(label: "SessionSnodeKit.workQueue", qos: .userInitiated) // It's important that this is a serial queue
|
||||
}
|
Loading…
Reference in New Issue