Implement onion request encryption

pull/148/head
gmbnt 5 years ago
parent fd037c2a88
commit 1b24637f37

@ -53,11 +53,11 @@ public extension LokiAPI {
let rawResponse = intermediate.responseObject 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 } 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 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).") print("[Loki] Failed to parse target from: \(rawTarget).")
return nil 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 // randomElement() uses the system's default random generator, which is cryptographically secure
return randomSnodePool.randomElement()! return randomSnodePool.randomElement()!
@ -132,11 +132,11 @@ public extension LokiAPI {
return [] return []
} }
return rawTargets.flatMap { rawTarget in 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).") print("[Loki] Failed to parse target from: \(rawTarget).")
return nil 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))
} }
} }
} }

@ -14,8 +14,8 @@ internal final class LokiAPITarget : NSObject, NSCoding {
} }
internal struct KeySet { internal struct KeySet {
let idKey: String let ed25519Key: String
let encryptionKey: String let x25519Key: String
} }
// MARK: Initialization // MARK: Initialization
@ -30,7 +30,7 @@ internal final class LokiAPITarget : NSObject, NSCoding {
address = coder.decodeObject(forKey: "address") as! String address = coder.decodeObject(forKey: "address") as! String
port = coder.decodeObject(forKey: "port") as! UInt16 port = coder.decodeObject(forKey: "port") as! UInt16
if let idKey = coder.decodeObject(forKey: "idKey") as? String, let encryptionKey = coder.decodeObject(forKey: "encryptionKey") as? String { 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 { } else {
publicKeySet = nil publicKeySet = nil
} }
@ -41,8 +41,8 @@ internal final class LokiAPITarget : NSObject, NSCoding {
coder.encode(address, forKey: "address") coder.encode(address, forKey: "address")
coder.encode(port, forKey: "port") coder.encode(port, forKey: "port")
if let keySet = publicKeySet { if let keySet = publicKeySet {
coder.encode(keySet.idKey, forKey: "idKey") coder.encode(keySet.ed25519Key, forKey: "idKey")
coder.encode(keySet.encryptionKey, forKey: "encryptionKey") coder.encode(keySet.x25519Key, forKey: "encryptionKey")
} }
} }

@ -34,7 +34,7 @@ internal class LokiSnodeProxy : LokiHTTPClient {
let headers = getCanonicalHeaders(for: request) let headers = getCanonicalHeaders(for: request)
return Promise<LokiAPI.RawResponse> { [target = self.target, keyPair = self.keyPair, httpSession = self.httpSession] seal in return Promise<LokiAPI.RawResponse> { [target = self.target, keyPair = self.keyPair, httpSession = self.httpSession] seal in
DispatchQueue.global().async { 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) } guard let symmetricKey = uncheckedSymmetricKey else { return seal.reject(Error.symmetricKeyGenerationFailed) }
LokiAPI.getRandomSnode().then(on: DispatchQueue.global()) { proxy -> Promise<LokiAPI.RawResponse> in LokiAPI.getRandomSnode().then(on: DispatchQueue.global()) { proxy -> Promise<LokiAPI.RawResponse> in
let url = "\(proxy.address):\(proxy.port)/proxy" let url = "\(proxy.address):\(proxy.port)/proxy"
@ -49,7 +49,7 @@ internal class LokiSnodeProxy : LokiHTTPClient {
let ivAndCipherText = try DiffieHellman.encrypt(proxyRequestParametersAsData, using: symmetricKey) let ivAndCipherText = try DiffieHellman.encrypt(proxyRequestParametersAsData, using: symmetricKey)
let proxyRequestHeaders = [ let proxyRequestHeaders = [
"X-Sender-Public-Key" : keyPair.publicKey.toHexString(), "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 (promise, resolver) = LokiAPI.RawResponsePromise.pending()
let proxyRequest = AFHTTPRequestSerializer().request(withMethod: "POST", urlString: url, parameters: nil, error: nil) let proxyRequest = AFHTTPRequestSerializer().request(withMethod: "POST", urlString: url, parameters: nil, error: nil)

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

@ -1,48 +1,44 @@
import PromiseKit import PromiseKit
import SignalMetadataKit
// TODO: Test path snodes as well // 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. /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information.
internal enum OnionRequestAPI { internal enum OnionRequestAPI {
private static let urlSessionDelegate = URLSessionDelegateImplementation()
private static let urlSession = URLSession(configuration: .ephemeral, delegate: urlSessionDelegate, delegateQueue: nil)
/// - Note: Exposed for testing purposes. /// - 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 let workQueue = DispatchQueue.global() // TODO: We should probably move away from using the global queue for this
internal static var guardSnodes: Set<LokiAPITarget> = [] internal static var guardSnodes: Set<LokiAPITarget> = []
internal static var paths: Set<Path> = [] internal static var paths: Set<Path> = []
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 // MARK: Settings
private static let pathCount: UInt = 3 private static let pathCount: UInt = 3
/// The number of snodes (including the guard snode) in a path. /// The number of snodes (including the guard snode) in a path.
private static let pathSize: UInt = 3 private static let pathSize: UInt = 3
private static let guardSnodeCount: 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 // MARK: Error
internal enum Error : LocalizedError { internal enum Error : LocalizedError {
case insufficientSnodes case insufficientSnodes
case snodePublicKeySetMissing case snodePublicKeySetMissing
case symmetricKeyGenerationFailed case randomDataGenerationFailed
case jsonSerializationFailed
case encryptionFailed
case generic case generic
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .insufficientSnodes: return "Couldn't find enough snodes to build a path." case .insufficientSnodes: return "Couldn't find enough snodes to build a path."
case .snodePublicKeySetMissing: return "Missing snode public key set." case .snodePublicKeySetMissing: return "Missing snode public key set."
case .symmetricKeyGenerationFailed: return "Couldn't generate symmetric key." case .randomDataGenerationFailed: return "Couldn't generate random data."
case .jsonSerializationFailed: return "Couldn't serialize JSON."
case .encryptionFailed: return "Couldn't encrypt request."
case .generic: return "An error occurred." 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 } 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. /// if not enough (reliable) snodes are available.
private static func getGuardSnodes() -> Promise<Set<LokiAPITarget>> { private static func getGuardSnodes() -> Promise<Set<LokiAPITarget>> {
if !guardSnodes.isEmpty { 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. /// if not enough (reliable) snodes are available.
private static func buildPaths() -> Promise<Set<Path>> { private static func buildPaths() -> Promise<Set<Path>> {
print("[Loki] [Onion Request API] Building onion request paths.") print("[Loki] [Onion Request API] Building onion request paths.")
@ -110,6 +106,7 @@ internal enum OnionRequestAPI {
guard unusedSnodes.count >= minSnodeCount else { throw Error.insufficientSnodes } guard unusedSnodes.count >= minSnodeCount else { throw Error.insufficientSnodes }
let result: Set<Path> = Set(guardSnodes.map { guardSnode in let result: Set<Path> = Set(guardSnodes.map { guardSnode in
// Force unwrapping is safe because of the minSnodeCount check above // 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()! } 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: ", "))") 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] { /// Returns a `Path` to be used for onion requests. Builds paths as needed.
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<URLRequest> {
let (promise, seal) = Promise<URLRequest>.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<TSRequest> {
return Promise<TSRequest> { $0.fulfill(request) }
}
private static func encrypt(_ request: TSRequest, forRelayFrom snode1: LokiAPITarget, to snode2: LokiAPITarget) -> Promise<TSRequest> {
return Promise<TSRequest> { $0.fulfill(request) }
}
// MARK: Internal API
/// Returns an `OnionRequestPath` to be used for onion requests. Builds new paths as needed.
/// ///
/// - Note: Should ideally only ever be invoked from `DispatchQueue.global()`. /// - Note: Should ideally only ever be invoked from `DispatchQueue.global()`.
internal static func getPath() -> Promise<Path> { private static func getPath() -> Promise<Path> {
// randomElement() uses the system's default random generator, which is cryptographically secure // randomElement() uses the system's default random generator, which is cryptographically secure
if paths.count >= pathCount { if paths.count >= pathCount {
return Promise<Path> { $0.fulfill(paths.randomElement()!) } return Promise<Path> { $0.fulfill(paths.randomElement()!) }
@ -182,38 +131,42 @@ internal enum OnionRequestAPI {
} }
} }
/// Sends an onion request to `snode`. Builds paths as needed. /// Builds an onion around `payload` and returns the result.
internal static func send(_ request: TSRequest, to snode: LokiAPITarget) -> Promise<Any> { private static func buildOnion(around payload: Data, targetedAt snode: LokiAPITarget) -> Promise<(guardSnode: LokiAPITarget, onion: Data)> {
var request = request var guardSnode: LokiAPITarget!
return getPath().then(on: workQueue) { path -> Promise<TSRequest> in return getPath().then(on: workQueue) { path -> Promise<EncryptionResult> in
guardSnode = path.first!
return encrypt(payload, forTargetSnode: snode).then(on: workQueue) { r -> Promise<EncryptionResult> in
var path = path var path = path
path.removeFirst() // Drop the guard snode var encryptionResult = r
return encrypt(request, forTargetSnode: snode).then(on: workQueue) { r -> Promise<TSRequest> in
request = r
var rhs = snode var rhs = snode
func encryptForNextLayer() -> Promise<TSRequest> { func addLayer() -> Promise<EncryptionResult> {
if path.isEmpty { if path.isEmpty {
return Promise<TSRequest> { $0.fulfill(request) } return Promise<EncryptionResult> { $0.fulfill(encryptionResult) }
} else { } else {
let lhs = path.removeLast() let lhs = path.removeLast()
return encrypt(request, forRelayFrom: lhs, to: rhs).then(on: workQueue) { r -> Promise<TSRequest> in return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then(on: workQueue) { e -> Promise<EncryptionResult> in
request = r encryptionResult = e
rhs = lhs rhs = lhs
return encryptForNextLayer() return addLayer()
}
} }
} }
return addLayer()
} }
return encryptForNextLayer() }.map { (guardSnode: guardSnode, onion: $0.ciphertext) }
} }
}.then { request -> Promise<Any> in
let (promise, seal) = LokiAPI.RawResponsePromise.pending() // MARK: Internal API
var task: URLSessionDataTask! /// Sends an onion request to `snode`. Builds paths as needed.
task = httpSession.dataTask(with: request as URLRequest) { response, result, error in internal static func send(_ request: URLRequest, to snode: LokiAPITarget) -> Promise<Any> {
return buildOnion(around: request.httpBody!, targetedAt: snode).then(on: workQueue) { intermediate -> Promise<Any> in
let guardSnode = intermediate.guardSnode
let onion = intermediate.onion
let (promise, seal) = Promise<Any>.pending()
let task = urlSession.dataTask(with: request) { response, result, error in
if let error = error { if let error = error {
let nmError = NetworkManagerError.taskError(task: task, underlyingError: error) seal.reject(error)
let nsError = nmError as NSError
nsError.isRetryable = false
seal.reject(nsError)
} else if let result = result { } else if let result = result {
seal.fulfill(result) seal.fulfill(result)
} else { } else {

Loading…
Cancel
Save