Implement onion request encryption

This commit is contained in:
gmbnt 2020-03-31 14:52:56 +11:00
parent fd037c2a88
commit 1b24637f37
5 changed files with 134 additions and 103 deletions

View file

@ -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))
}
}
}

View file

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

View file

@ -34,7 +34,7 @@ internal class LokiSnodeProxy : LokiHTTPClient {
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)
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<LokiAPI.RawResponse> 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)

View file

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

View file

@ -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<LokiAPITarget> = []
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
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<Set<LokiAPITarget>> {
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<Set<Path>> {
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<Path> = 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<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.
/// 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<Path> {
private static func getPath() -> Promise<Path> {
// randomElement() uses the system's default random generator, which is cryptographically secure
if paths.count >= pathCount {
return Promise<Path> { $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<Any> {
var request = request
return getPath().then(on: workQueue) { path -> Promise<TSRequest> in
var path = path
path.removeFirst() // Drop the guard snode
return encrypt(request, forTargetSnode: snode).then(on: workQueue) { r -> Promise<TSRequest> 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<EncryptionResult> in
guardSnode = path.first!
return encrypt(payload, forTargetSnode: snode).then(on: workQueue) { r -> Promise<EncryptionResult> in
var path = path
var encryptionResult = r
var rhs = snode
func encryptForNextLayer() -> Promise<TSRequest> {
func addLayer() -> Promise<EncryptionResult> {
if path.isEmpty {
return Promise<TSRequest> { $0.fulfill(request) }
return Promise<EncryptionResult> { $0.fulfill(encryptionResult) }
} else {
let lhs = path.removeLast()
return encrypt(request, forRelayFrom: lhs, to: rhs).then(on: workQueue) { r -> Promise<TSRequest> in
request = r
return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then(on: workQueue) { e -> Promise<EncryptionResult> in
encryptionResult = e
rhs = lhs
return encryptForNextLayer()
return addLayer()
}
}
}
return encryptForNextLayer()
return addLayer()
}
}.then { request -> Promise<Any> 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<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 {
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 {