mirror of https://github.com/oxen-io/session-ios
Merge branch 'dev' into push-notifications
commit
03c4e4a65c
@ -0,0 +1,93 @@
|
||||
import PromiseKit
|
||||
|
||||
internal enum HTTP {
|
||||
private static let urlSession = URLSession(configuration: .ephemeral, delegate: urlSessionDelegate, delegateQueue: nil)
|
||||
private static let urlSessionDelegate = URLSessionDelegateImplementation()
|
||||
|
||||
// MARK: Settings
|
||||
private static let timeout: TimeInterval = 20
|
||||
|
||||
// MARK: URL Session Delegate Implementation
|
||||
private final class URLSessionDelegateImplementation : NSObject, URLSessionDelegate {
|
||||
|
||||
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
// Snode to snode communication uses self-signed certificates but clients can safely ignore this
|
||||
completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Verb
|
||||
internal enum Verb : String {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
// MARK: Error
|
||||
internal enum Error : LocalizedError {
|
||||
case generic
|
||||
case httpRequestFailed(statusCode: UInt, json: JSON?)
|
||||
case invalidJSON
|
||||
|
||||
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
|
||||
internal static func execute(_ verb: Verb, _ url: String, parameters: JSON? = nil, timeout: TimeInterval = HTTP.timeout) -> Promise<JSON> {
|
||||
return Promise<JSON> { seal in
|
||||
var request = URLRequest(url: URL(string: url)!)
|
||||
request.httpMethod = verb.rawValue
|
||||
if let parameters = parameters {
|
||||
do {
|
||||
guard JSONSerialization.isValidJSONObject(parameters) else { return seal.reject(Error.invalidJSON) }
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||
} catch (let error) {
|
||||
return seal.reject(error)
|
||||
}
|
||||
}
|
||||
request.timeoutInterval = timeout
|
||||
let task = urlSession.dataTask(with: request) { data, response, error in
|
||||
guard let data = data, let response = response as? HTTPURLResponse else {
|
||||
if let error = error {
|
||||
print("[Loki] \(verb.rawValue) request to \(url) failed due to error: \(error).")
|
||||
} else {
|
||||
print("[Loki] \(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 {
|
||||
print("[Loki] \(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: []) 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"
|
||||
print("[Loki] \(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 {
|
||||
print("[Loki] Couldn't parse JSON returned by \(verb.rawValue) request to \(url).")
|
||||
return seal.reject(Error.invalidJSON)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
import PromiseKit
|
||||
import SignalMetadataKit
|
||||
|
||||
internal class LokiSnodeProxy : LokiHTTPClient {
|
||||
private let target: LokiAPITarget
|
||||
private let keyPair = Curve25519.generateKeyPair()
|
||||
|
||||
// MARK: Error
|
||||
internal enum Error : LocalizedError {
|
||||
case targetPublicKeySetMissing
|
||||
case symmetricKeyGenerationFailed
|
||||
case proxyResponseParsingFailed
|
||||
case targetSnodeHTTPError(code: Int, message: Any?)
|
||||
|
||||
internal var errorDescription: String? {
|
||||
switch self {
|
||||
case .targetPublicKeySetMissing: return "Missing target public key set."
|
||||
case .symmetricKeyGenerationFailed: return "Couldn't generate symmetric key."
|
||||
case .proxyResponseParsingFailed: return "Couldn't parse snode proxy response."
|
||||
case .targetSnodeHTTPError(let httpStatusCode, let message): return "Target snode returned error \(httpStatusCode) with description: \(message ?? "no description provided.")."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
internal init(for target: LokiAPITarget) {
|
||||
self.target = target
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Proxying
|
||||
override internal func perform(_ request: TSRequest, withCompletionQueue queue: DispatchQueue = DispatchQueue.main) -> LokiAPI.RawResponsePromise {
|
||||
guard let targetHexEncodedPublicKeySet = target.publicKeySet else { return Promise(error: Error.targetPublicKeySetMissing) }
|
||||
let headers = getCanonicalHeaders(for: request)
|
||||
return Promise<LokiAPI.RawResponse> { [target = self.target, keyPair = self.keyPair, httpSession = self.httpSession] seal in
|
||||
DispatchQueue.global().async {
|
||||
let uncheckedSymmetricKey = try? Curve25519.generateSharedSecret(fromPublicKey: Data(hex: targetHexEncodedPublicKeySet.encryptionKey), privateKey: keyPair.privateKey)
|
||||
guard let symmetricKey = uncheckedSymmetricKey else { return seal.reject(Error.symmetricKeyGenerationFailed) }
|
||||
LokiAPI.getRandomSnode().then(on: DispatchQueue.global()) { proxy -> Promise<LokiAPI.RawResponse> in
|
||||
let url = "\(proxy.address):\(proxy.port)/proxy"
|
||||
print("[Loki] Proxying request to \(target) through \(proxy).")
|
||||
let parametersAsData = try JSONSerialization.data(withJSONObject: request.parameters, options: [])
|
||||
let proxyRequestParameters: JSON = [
|
||||
"method" : request.httpMethod,
|
||||
"body" : String(bytes: parametersAsData, encoding: .utf8),
|
||||
"headers" : headers
|
||||
]
|
||||
let proxyRequestParametersAsData = try JSONSerialization.data(withJSONObject: proxyRequestParameters, options: [])
|
||||
let ivAndCipherText = try DiffieHellman.encrypt(proxyRequestParametersAsData, using: symmetricKey)
|
||||
let proxyRequestHeaders = [
|
||||
"X-Sender-Public-Key" : keyPair.publicKey.toHexString(),
|
||||
"X-Target-Snode-Key" : targetHexEncodedPublicKeySet.idKey
|
||||
]
|
||||
let (promise, resolver) = LokiAPI.RawResponsePromise.pending()
|
||||
let proxyRequest = AFHTTPRequestSerializer().request(withMethod: "POST", urlString: url, parameters: nil, error: nil)
|
||||
proxyRequest.allHTTPHeaderFields = proxyRequestHeaders
|
||||
proxyRequest.httpBody = ivAndCipherText
|
||||
proxyRequest.timeoutInterval = request.timeoutInterval
|
||||
var task: URLSessionDataTask!
|
||||
task = httpSession.dataTask(with: proxyRequest as URLRequest) { response, result, error in
|
||||
if let error = error {
|
||||
let nmError = NetworkManagerError.taskError(task: task, underlyingError: error)
|
||||
let nsError: NSError = nmError as NSError
|
||||
nsError.isRetryable = false
|
||||
resolver.reject(nsError)
|
||||
} else {
|
||||
resolver.fulfill(result)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
return promise
|
||||
}.map(on: DispatchQueue.global()) { rawResponse in
|
||||
guard let responseAsData = rawResponse as? Data, let cipherText = Data(base64Encoded: responseAsData) else {
|
||||
print("[Loki] Received a non-string encoded response.")
|
||||
return rawResponse
|
||||
}
|
||||
let response = try DiffieHellman.decrypt(cipherText, using: symmetricKey)
|
||||
let uncheckedJSON = try? JSONSerialization.jsonObject(with: response, options: .allowFragments) as? JSON
|
||||
guard let json = uncheckedJSON, let statusCode = json["status"] as? Int else { throw HTTPError.networkError(code: -1, response: nil, underlyingError: Error.proxyResponseParsingFailed) }
|
||||
let isSuccess = (200...299) ~= statusCode
|
||||
var body: Any? = nil
|
||||
if let bodyAsString = json["body"] as? String {
|
||||
body = bodyAsString
|
||||
if let bodyAsJSON = try? JSONSerialization.jsonObject(with: bodyAsString.data(using: .utf8)!, options: .allowFragments) as? JSON {
|
||||
body = bodyAsJSON
|
||||
}
|
||||
}
|
||||
guard isSuccess else { throw HTTPError.networkError(code: statusCode, response: body, underlyingError: Error.targetSnodeHTTPError(code: statusCode, message: body)) }
|
||||
return body
|
||||
}.done { rawResponse in
|
||||
seal.fulfill(rawResponse)
|
||||
}.catch { error in
|
||||
print("[Loki] Proxy request failed with error: \(error.localizedDescription).")
|
||||
seal.reject(HTTPError.from(error: error) ?? error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import CryptoSwift
|
||||
import PromiseKit
|
||||
|
||||
extension OnionRequestAPI {
|
||||
internal static let gcmTagSize: UInt = 16
|
||||
internal static let ivSize: UInt = 12
|
||||
|
||||
internal typealias EncryptionResult = (ciphertext: Data, symmetricKey: Data, ephemeralPublicKey: Data)
|
||||
|
||||
private static func getQueue() -> DispatchQueue {
|
||||
return DispatchQueue(label: UUID().uuidString, qos: .userInitiated)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private static func getSecureRandomData(ofSize size: UInt) throws -> Data {
|
||||
var data = Data(count: Int(size))
|
||||
let result = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, Int(size), $0.baseAddress!) }
|
||||
guard result == errSecSuccess else { throw Error.randomDataGenerationFailed }
|
||||
return data
|
||||
}
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
private static func encrypt(_ plaintext: Data, usingAESGCMWithSymmetricKey symmetricKey: Data) throws -> Data {
|
||||
guard !Thread.isMainThread else { preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") }
|
||||
let iv = try 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(bytes: ciphertext)
|
||||
}
|
||||
|
||||
/// - Note: Sync. Don't call from the main thread.
|
||||
private static func encrypt(_ plaintext: Data, forSnode snode: LokiAPITarget) throws -> EncryptionResult {
|
||||
guard !Thread.isMainThread else { preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") }
|
||||
guard let hexEncodedSnodeX25519PublicKey = snode.publicKeySet?.x25519Key else { throw Error.snodePublicKeySetMissing }
|
||||
let snodeX25519PublicKey = Data(hex: hexEncodedSnodeX25519PublicKey)
|
||||
let ephemeralKeyPair = Curve25519.generateKeyPair()
|
||||
let ephemeralSharedSecret = try Curve25519.generateSharedSecret(fromPublicKey: snodeX25519PublicKey, privateKey: ephemeralKeyPair.privateKey)
|
||||
let key = "LOKI"
|
||||
let symmetricKey = try HMAC(key: key.bytes, variant: .sha256).authenticate(ephemeralSharedSecret.bytes)
|
||||
let ciphertext = try encrypt(plaintext, usingAESGCMWithSymmetricKey: Data(bytes: symmetricKey))
|
||||
return (ciphertext, Data(bytes: symmetricKey), ephemeralKeyPair.publicKey)
|
||||
}
|
||||
|
||||
/// Encrypts `payload` for `snode` and returns the result. Use this to build the core of an onion request.
|
||||
internal static func encrypt(_ payload: JSON, forTargetSnode snode: LokiAPITarget) -> Promise<EncryptionResult> {
|
||||
let (promise, seal) = Promise<EncryptionResult>.pending()
|
||||
getQueue().async {
|
||||
do {
|
||||
guard JSONSerialization.isValidJSONObject(payload) else { return seal.reject(HTTP.Error.invalidJSON) }
|
||||
let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
let payloadAsString = String(data: payloadAsData, encoding: .utf8)! // Snodes only accept this as a string
|
||||
let wrapper: JSON = [ "body" : payloadAsString, "headers" : "" ]
|
||||
guard JSONSerialization.isValidJSONObject(wrapper) else { return seal.reject(HTTP.Error.invalidJSON) }
|
||||
let plaintext = try JSONSerialization.data(withJSONObject: wrapper, options: [])
|
||||
let result = try encrypt(plaintext, forSnode: snode)
|
||||
seal.fulfill(result)
|
||||
} catch (let error) {
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request.
|
||||
internal static func encryptHop(from lhs: LokiAPITarget, to rhs: LokiAPITarget, using previousEncryptionResult: EncryptionResult) -> Promise<EncryptionResult> {
|
||||
let (promise, seal) = Promise<EncryptionResult>.pending()
|
||||
getQueue().async {
|
||||
let parameters: JSON = [
|
||||
"ciphertext" : previousEncryptionResult.ciphertext.base64EncodedString(),
|
||||
"ephemeral_key" : previousEncryptionResult.ephemeralPublicKey.toHexString(),
|
||||
"destination" : rhs.publicKeySet!.ed25519Key
|
||||
]
|
||||
do {
|
||||
guard JSONSerialization.isValidJSONObject(parameters) else { return seal.reject(HTTP.Error.invalidJSON) }
|
||||
let plaintext = try JSONSerialization.data(withJSONObject: parameters, options: [])
|
||||
let result = try encrypt(plaintext, forSnode: lhs)
|
||||
seal.fulfill(result)
|
||||
} catch (let error) {
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
}
|
@ -0,0 +1,274 @@
|
||||
import CryptoSwift
|
||||
import PromiseKit
|
||||
|
||||
/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
|
||||
internal enum OnionRequestAPI {
|
||||
/// - Note: Exposed for testing purposes.
|
||||
internal static let workQueue = DispatchQueue(label: "OnionRequestAPI.workQueue", qos: .userInitiated)
|
||||
/// - Note: Must only be modified from `workQueue`.
|
||||
internal static var guardSnodes: Set<LokiAPITarget> = []
|
||||
/// - Note: Must only be modified from `workQueue`.
|
||||
internal static var paths: Set<Path> = []
|
||||
|
||||
private static var snodePool: Set<LokiAPITarget> {
|
||||
let unreliableSnodes = Set(LokiAPI.failureCount.keys)
|
||||
return LokiAPI.randomSnodePool.subtracting(unreliableSnodes)
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
private static let pathCount: UInt = 2
|
||||
/// The number of snodes (including the guard snode) in a path.
|
||||
private static let pathSize: UInt = 1
|
||||
|
||||
private static var guardSnodeCount: UInt { return pathCount } // One per path
|
||||
|
||||
// MARK: Error
|
||||
internal enum Error : LocalizedError {
|
||||
case httpRequestFailedAtTargetSnode(statusCode: UInt, json: JSON)
|
||||
case insufficientSnodes
|
||||
case missingSnodeVersion
|
||||
case randomDataGenerationFailed
|
||||
case snodePublicKeySetMissing
|
||||
case unsupportedSnodeVersion(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .httpRequestFailedAtTargetSnode(let statusCode): return "HTTP request failed at target snode with status code: \(statusCode)."
|
||||
case .insufficientSnodes: return "Couldn't find enough snodes to build a path."
|
||||
case .missingSnodeVersion: return "Missing snode version."
|
||||
case .randomDataGenerationFailed: return "Couldn't generate random data."
|
||||
case .snodePublicKeySetMissing: return "Missing snode public key set."
|
||||
case .unsupportedSnodeVersion(let version): return "Unsupported snode version: \(version)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Path
|
||||
internal typealias Path = [LokiAPITarget]
|
||||
|
||||
// MARK: Onion Building Result
|
||||
private typealias OnionBuildingResult = (guardSnode: LokiAPITarget, finalEncryptionResult: EncryptionResult, targetSnodeSymmetricKey: 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: LokiAPITarget) -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let queue = DispatchQueue(label: UUID().uuidString, qos: .userInitiated) // No need to block the work queue for this
|
||||
queue.async {
|
||||
let url = "\(snode.address):\(snode.port)/get_stats/v1"
|
||||
let timeout: TimeInterval = 6 // Use a shorter timeout for testing
|
||||
HTTP.execute(.get, url, timeout: timeout).done(on: queue) { rawResponse in
|
||||
guard let json = rawResponse as? JSON, let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) }
|
||||
if version >= "2.0.0" {
|
||||
seal.fulfill(())
|
||||
} else {
|
||||
print("[Loki] [Onion Request API] Unsupported snode version: \(version).")
|
||||
seal.reject(Error.unsupportedSnodeVersion(version))
|
||||
}
|
||||
}.catch(on: queue) { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
/// Finds `guardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
private static func getGuardSnodes() -> Promise<Set<LokiAPITarget>> {
|
||||
if guardSnodes.count >= guardSnodeCount {
|
||||
return Promise<Set<LokiAPITarget>> { $0.fulfill(guardSnodes) }
|
||||
} else {
|
||||
print("[Loki] [Onion Request API] Populating guard snode cache.")
|
||||
return LokiAPI.getRandomSnode().then(on: workQueue) { _ -> Promise<Set<LokiAPITarget>> in // Just used to populate the snode pool
|
||||
var unusedSnodes = snodePool // Sync on workQueue
|
||||
guard unusedSnodes.count >= guardSnodeCount else { throw Error.insufficientSnodes }
|
||||
func getGuardSnode() -> Promise<LokiAPITarget> {
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
guard let candidate = unusedSnodes.randomElement() else { return Promise<LokiAPITarget> { $0.reject(Error.insufficientSnodes) } }
|
||||
unusedSnodes.remove(candidate) // All used snodes should be unique
|
||||
print("[Loki] [Onion Request API] Testing guard snode: \(candidate).")
|
||||
// Loop until a reliable guard snode is found
|
||||
return testSnode(candidate).map(on: workQueue) { candidate }.recover(on: workQueue) { _ in getGuardSnode() }
|
||||
}
|
||||
let promises = (0..<guardSnodeCount).map { _ in getGuardSnode() }
|
||||
return when(fulfilled: promises).map(on: workQueue) { guardSnodes in
|
||||
let guardSnodesAsSet = Set(guardSnodes)
|
||||
OnionRequestAPI.guardSnodes = guardSnodesAsSet
|
||||
return guardSnodesAsSet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds and returns `pathCount` paths. The returned promise errors out with `Error.insufficientSnodes`
|
||||
/// if not enough (reliable) snodes are available.
|
||||
private static func buildPaths() -> Promise<Set<Path>> {
|
||||
print("[Loki] [Onion Request API] Building onion request paths.")
|
||||
return LokiAPI.getRandomSnode().then(on: workQueue) { _ -> Promise<Set<Path>> in // Just used to populate the snode pool
|
||||
return getGuardSnodes().map(on: workQueue) { guardSnodes in
|
||||
var unusedSnodes = snodePool.subtracting(guardSnodes)
|
||||
let pathSnodeCount = guardSnodeCount * pathSize - guardSnodeCount
|
||||
guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes }
|
||||
// Don't test path snodes as this would reveal the user's IP to them
|
||||
return Set(guardSnodes.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 minSnodeCount check above
|
||||
unusedSnodes.remove(pathSnode) // All used snodes should be unique
|
||||
return pathSnode
|
||||
}
|
||||
print("[Loki] [Onion Request API] Built new onion request path: \(result.prettifiedDescription).")
|
||||
return result
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a `Path` to be used for building an onion request. Builds new paths as needed.
|
||||
///
|
||||
/// - Note: Exposed for testing purposes.
|
||||
internal static func getPath(excluding snode: LokiAPITarget) -> Promise<Path> {
|
||||
guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") }
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
if paths.count >= pathCount {
|
||||
return Promise<Path> { seal in
|
||||
seal.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!)
|
||||
}
|
||||
} else {
|
||||
return buildPaths().map(on: workQueue) { paths in
|
||||
let path = paths.filter { !$0.contains(snode) }.randomElement()!
|
||||
OnionRequestAPI.paths = paths
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func dropPath(containing snode: LokiAPITarget) {
|
||||
paths = paths.filter { !$0.contains(snode) }
|
||||
}
|
||||
|
||||
/// Builds an onion around `payload` and returns the result.
|
||||
private static func buildOnion(around payload: JSON, targetedAt snode: LokiAPITarget) -> Promise<OnionBuildingResult> {
|
||||
var guardSnode: LokiAPITarget!
|
||||
var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the target snode
|
||||
var encryptionResult: EncryptionResult!
|
||||
return getPath(excluding: snode).then(on: workQueue) { path -> Promise<EncryptionResult> in
|
||||
guardSnode = path.first!
|
||||
// Encrypt in reverse order, i.e. the target snode first
|
||||
return encrypt(payload, forTargetSnode: snode).then(on: workQueue) { r -> Promise<EncryptionResult> in
|
||||
targetSnodeSymmetricKey = r.symmetricKey
|
||||
// Recursively encrypt the layers of the onion (again in reverse order)
|
||||
encryptionResult = r
|
||||
var path = path
|
||||
var rhs = snode
|
||||
func addLayer() -> Promise<EncryptionResult> {
|
||||
if path.isEmpty {
|
||||
return Promise<EncryptionResult> { $0.fulfill(encryptionResult) }
|
||||
} else {
|
||||
let lhs = path.removeLast()
|
||||
return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then(on: workQueue) { r -> Promise<EncryptionResult> in
|
||||
encryptionResult = r
|
||||
rhs = lhs
|
||||
return addLayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
return addLayer()
|
||||
}
|
||||
}.map(on: workQueue) { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) }
|
||||
}
|
||||
|
||||
// MARK: Internal API
|
||||
/// Sends an onion request to `snode`. Builds new paths as needed.
|
||||
internal static func sendOnionRequest(invoking method: LokiAPITarget.Method, on snode: LokiAPITarget, with parameters: JSON, associatedWith hexEncodedPublicKey: String) -> Promise<JSON> {
|
||||
let (promise, seal) = Promise<JSON>.pending()
|
||||
var guardSnode: LokiAPITarget!
|
||||
workQueue.async {
|
||||
let payload: JSON = [ "method" : method.rawValue, "params" : parameters ]
|
||||
buildOnion(around: payload, targetedAt: snode).done(on: workQueue) { intermediate in
|
||||
guardSnode = intermediate.guardSnode
|
||||
let url = "\(guardSnode.address):\(guardSnode.port)/onion_req"
|
||||
let finalEncryptionResult = intermediate.finalEncryptionResult
|
||||
let onion = finalEncryptionResult.ciphertext
|
||||
let parameters: JSON = [
|
||||
"ciphertext" : onion.base64EncodedString(),
|
||||
"ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString()
|
||||
]
|
||||
let targetSnodeSymmetricKey = intermediate.targetSnodeSymmetricKey
|
||||
HTTP.execute(.post, url, parameters: parameters).done(on: workQueue) { rawResponse in
|
||||
guard let json = rawResponse as? JSON, let base64EncodedIVAndCiphertext = json["result"] as? String,
|
||||
let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext) else { return seal.reject(HTTP.Error.invalidJSON) }
|
||||
let iv = ivAndCiphertext[0..<Int(ivSize)]
|
||||
let ciphertext = ivAndCiphertext[Int(ivSize)...]
|
||||
do {
|
||||
let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagSize), mode: .combined)
|
||||
let aes = try AES(key: targetSnodeSymmetricKey.bytes, blockMode: gcm, padding: .noPadding)
|
||||
let data = Data(try aes.decrypt(ciphertext.bytes))
|
||||
guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? JSON,
|
||||
let bodyAsString = json["body"] as? String, let bodyAsData = bodyAsString.data(using: .utf8),
|
||||
let body = try JSONSerialization.jsonObject(with: bodyAsData, options: []) as? JSON,
|
||||
let statusCode = json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) }
|
||||
guard 200...299 ~= statusCode else { return seal.reject(Error.httpRequestFailedAtTargetSnode(statusCode: UInt(statusCode), json: body)) }
|
||||
seal.fulfill(body)
|
||||
} catch (let error) {
|
||||
seal.reject(error)
|
||||
}
|
||||
}.catch(on: workQueue) { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
}.catch(on: workQueue) { error in
|
||||
seal.reject(error)
|
||||
}
|
||||
}
|
||||
promise.catch(on: workQueue) { error in // Must be invoked on workQueue
|
||||
guard case HTTP.Error.httpRequestFailed(_, _) = error else { return }
|
||||
dropPath(containing: guardSnode) // A snode in the path is bad; retry with a different path
|
||||
}
|
||||
promise.handlingErrorsIfNeeded(forTargetSnode: snode, associatedWith: hexEncodedPublicKey)
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Target Snode Error Handling
|
||||
private extension Promise where T == JSON {
|
||||
|
||||
func handlingErrorsIfNeeded(forTargetSnode snode: LokiAPITarget, associatedWith hexEncodedPublicKey: String) -> Promise<JSON> {
|
||||
return recover(on: LokiAPI.errorHandlingQueue) { error -> Promise<JSON> in // Must be invoked on LokiAPI.errorHandlingQueue
|
||||
// The code below is very similar to that in LokiAPI.handlingSnodeErrorsIfNeeded(for:associatedWith:), but unfortunately slightly
|
||||
// different due to the fact that OnionRequestAPI uses the newer HTTP API, whereas LokiAPI still uses TSNetworkManager
|
||||
guard case OnionRequestAPI.Error.httpRequestFailedAtTargetSnode(let statusCode, let json) = error else { throw error }
|
||||
switch statusCode {
|
||||
case 0, 400, 500, 503:
|
||||
// The snode is unreachable
|
||||
let oldFailureCount = LokiAPI.failureCount[snode] ?? 0
|
||||
let newFailureCount = oldFailureCount + 1
|
||||
LokiAPI.failureCount[snode] = newFailureCount
|
||||
print("[Loki] Couldn't reach snode at: \(snode); setting failure count to \(newFailureCount).")
|
||||
if newFailureCount >= LokiAPI.failureThreshold {
|
||||
print("[Loki] Failure threshold reached for: \(snode); dropping it.")
|
||||
LokiAPI.dropIfNeeded(snode, hexEncodedPublicKey: hexEncodedPublicKey) // Remove it from the swarm cache associated with the given public key
|
||||
LokiAPI.randomSnodePool.remove(snode) // Remove it from the random snode pool
|
||||
LokiAPI.failureCount[snode] = 0
|
||||
}
|
||||
case 406:
|
||||
print("[Loki] The user's clock is out of sync with the service node network.")
|
||||
throw LokiAPI.LokiAPIError.clockOutOfSync
|
||||
case 421:
|
||||
// The snode isn't associated with the given public key anymore
|
||||
print("[Loki] Invalidating swarm for: \(hexEncodedPublicKey).")
|
||||
LokiAPI.dropIfNeeded(snode, hexEncodedPublicKey: hexEncodedPublicKey)
|
||||
case 432:
|
||||
// The proof of work difficulty is too low
|
||||
if let powDifficulty = json["difficulty"] as? Int {
|
||||
print("[Loki] Setting proof of work difficulty to \(powDifficulty).")
|
||||
LokiAPI.powDifficulty = UInt(powDifficulty)
|
||||
} else {
|
||||
print("[Loki] Failed to update proof of work difficulty.")
|
||||
}
|
||||
break
|
||||
default: break
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import CryptoSwift
|
||||
import PromiseKit
|
||||
@testable import SignalServiceKit
|
||||
import XCTest
|
||||
|
||||
class OnionRequestAPITests : XCTestCase {
|
||||
|
||||
func testOnionRequestSending() {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var totalSuccessRate: Double = 0
|
||||
let testCount = 10
|
||||
LokiAPI.getRandomSnode().then(on: OnionRequestAPI.workQueue) { snode -> Promise<LokiAPITarget> in
|
||||
print("[Loki] [Onion Request API] Target snode: \(snode).")
|
||||
return OnionRequestAPI.getPath(excluding: snode).map(on: OnionRequestAPI.workQueue) { _ in snode } // Ensure we only build a path once
|
||||
}.done(on: OnionRequestAPI.workQueue) { snode in
|
||||
var successCount = 0
|
||||
let promises: [Promise<Void>] = (0..<testCount).map { _ in
|
||||
let mockSessionID = "0582bc30f11e8a9736407adcaca03b049f4acd4af3ae7eb6b6608d30f0b1e6a20e"
|
||||
let parameters: JSON = [ "pubKey" : mockSessionID ]
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
OnionRequestAPI.sendOnionRequest(invoking: .getSwarm, on: snode, with: parameters, associatedWith: mockSessionID).done(on: OnionRequestAPI.workQueue) { json in
|
||||
successCount += 1
|
||||
print("[Loki] [Onion Request API] Onion request succeeded with result: \(json.prettifiedDescription).")
|
||||
seal.fulfill(())
|
||||
}.catch(on: OnionRequestAPI.workQueue) { error in
|
||||
if case GCM.Error.fail = error {
|
||||
print("[Loki] [Onion Request API] Onion request failed due to a decryption error.")
|
||||
} else {
|
||||
print("[Loki] [Onion Request API] Onion request failed due to error: \(error).")
|
||||
}
|
||||
seal.reject(error)
|
||||
}.finally(on: OnionRequestAPI.workQueue) {
|
||||
let currentSuccessRate = min((100 * Double(successCount)) / Double(testCount), 100)
|
||||
print("[Loki] [Onion Request API] Current onion request success rate: \(String(format: "%.1f", currentSuccessRate))%.")
|
||||
}
|
||||
return promise
|
||||
}
|
||||
when(resolved: promises).done(on: OnionRequestAPI.workQueue) { _ in
|
||||
totalSuccessRate = min((100 * Double(successCount)) / Double(testCount), 100)
|
||||
semaphore.signal()
|
||||
}
|
||||
}.catch(on: OnionRequestAPI.workQueue) { error in
|
||||
print("[Loki] [Onion Request API] Path building failed due to error: \(error).")
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
print("[Loki] [Onion Request API] Total onion request success rate: \(String(format: "%.1f", totalSuccessRate))%.")
|
||||
XCTAssert(totalSuccessRate >= 90)
|
||||
}
|
||||
|
||||
// TODO: Test error handling
|
||||
// TODO: Test race condition handling
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
|
||||
public extension Array where Element : CustomStringConvertible {
|
||||
|
||||
public var prettifiedDescription: String {
|
||||
return "[ " + map { $0.description }.joined(separator: ", ") + " ]"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue