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