diff --git a/Riot/Modules/Rendezvous/MockRendezvousTransport.swift b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift new file mode 100644 index 0000000000..2761ea989e --- /dev/null +++ b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift @@ -0,0 +1,57 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +class MockRendezvousTransport: RendezvousTransportProtocol { + var rendezvousURL: URL? + + private var currentPayload: Data? + + func create(body: T) async -> Result<(), RendezvousTransportError> { + guard let url = URL(string: "rendezvous.mock/1234") else { + fatalError() + } + + rendezvousURL = url + + guard let encodedBody = try? JSONEncoder().encode(body) else { + fatalError() + } + + currentPayload = encodedBody + + return .success(()) + } + + func get() async -> Result { + guard let data = currentPayload else { + fatalError() + } + + return .success(data) + } + + func send(body: T) async -> Result<(), RendezvousTransportError> { + guard let encodedBody = try? JSONEncoder().encode(body) else { + fatalError() + } + + currentPayload = encodedBody + + return .success(()) + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousModels.swift b/Riot/Modules/Rendezvous/RendezvousModels.swift new file mode 100644 index 0000000000..24edbf1cfd --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousModels.swift @@ -0,0 +1,38 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct RendezvousPayload: Codable { + var rendezvous: RendezvousDetails + var user: String +} + +struct RendezvousDetails: Codable { + var transport: RendezvousTransportDetails? + var algorithm: String + var key: String +} + +struct RendezvousTransportDetails: Codable { + var type: String + var uri: String +} + +struct RendezvousMessage: Codable { + var iv: String + var ciphertext: String +} diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift new file mode 100644 index 0000000000..84583a583a --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -0,0 +1,212 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import CryptoKit +import Combine + +enum RendezvousServiceError: Error { + case invalidInterlocutorKey + case decodingError + case internalError + case channelNotReady + case transportError(RendezvousTransportError) +} + +/// Algorithm name as per MSC3903 +enum RendezvousChannelAlgorithm: String { + case ECDH_V1 = "m.rendezvous.v1.curve25519-aes-sha256" +} + +/// Allows communication through a secure channel. Based on MSC3886 and MSC3903 +@MainActor +class RendezvousService { + private let transport: RendezvousTransportProtocol + private let privateKey: Curve25519.KeyAgreement.PrivateKey + + private var interlocutorPublicKey: Curve25519.KeyAgreement.PublicKey? + private var symmetricKey: SymmetricKey? + + init(transport: RendezvousTransportProtocol) { + self.transport = transport + self.privateKey = Curve25519.KeyAgreement.PrivateKey() + } + + /// Creates a new rendezvous endpoint and publishes the creator's public key + func createRendezvous() async -> Result<(), RendezvousServiceError> { + let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() + let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + key: publicKeyString) + + switch await transport.create(body: payload) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + return .success(()) + } + } + + /// After creation we need to wait for the pair to publish its public key as well + /// At the end of this a symmetric key will be available for encryption + func waitForInterlocutor() async -> Result<(), RendezvousServiceError> { + switch await transport.get() { + case .failure(let error): + return .failure(.transportError(error)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousDetails.self, from: data) else { + return .failure(.decodingError) + } + + guard let interlocutorPublicKeyData = Data(base64Encoded: response.key), + let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + return .failure(.invalidInterlocutorKey) + } + + self.interlocutorPublicKey = interlocutorPublicKey + + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + return .failure(.internalError) + } + + self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret) + + return .success(()) + } + } + + /// Joins an existing rendezvous and publishes the joiner's public key + /// At the end of this a symmetric key will be available for encryption + func joinRendezvous() async -> Result<(), RendezvousServiceError> { + guard case let .success(data) = await transport.get() else { + return .failure(.internalError) + } + + guard let response = try? JSONDecoder().decode(RendezvousDetails.self, from: data) else { + return .failure(.decodingError) + } + + guard let interlocutorPublicKeyData = Data(base64Encoded: response.key), + let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + return .failure(.invalidInterlocutorKey) + } + + self.interlocutorPublicKey = interlocutorPublicKey + + let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() + let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + key: publicKeyString) + + guard case .success = await transport.send(body: payload) else { + return .failure(.internalError) + } + + // Channel established + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + return .failure(.internalError) + } + + self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret) + + return .success(()) + } + + /// Send arbitrary data over the secure channel + /// This will use the previously generated symmetric key to AES encrypt the payload + /// - Parameter data: the data to be encrypted and sent + /// - Returns: nothing if succeeded or a RendezvousServiceError failure + func send(data: Data) async -> Result<(), RendezvousServiceError> { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + // Generate a custom random 256 bit nonce/iv as per MSC3903. The default one is 96 bit. + guard let nonce = try? AES.GCM.Nonce(data: generateRandomData(ofLength: 32)), + let sealedBox = try? AES.GCM.seal(data, using: symmetricKey, nonce: nonce) else { + return .failure(.internalError) + } + + // The resulting cipher text needs to contain both the message and the authentication tag + // in order to play nicely with other platforms + var ciphertext = sealedBox.ciphertext + ciphertext.append(contentsOf: sealedBox.tag) + + let body = RendezvousMessage(iv: Data(nonce).base64EncodedString(), + ciphertext: ciphertext.base64EncodedString()) + + switch await transport.send(body: body) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + return .success(()) + } + } + + + /// Waits for and returns newly available rendezvous channel data + /// - Returns: The unencrypted data or a RendezvousServiceError + func receive() async -> Result { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + switch await transport.get() { + case.failure(let transportError): + return .failure(.transportError(transportError)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousMessage.self, from: data) else { + return .failure(.decodingError) + } + + guard let ciphertextData = Data(base64Encoded: response.ciphertext), + let nonceData = Data(base64Encoded: response.iv), + let nonce = try? AES.GCM.Nonce(data: nonceData) else { + return .failure(.decodingError) + } + + // Split the ciphertext into the message and authentication tag data + let messageData = ciphertextData.dropLast(16) // The last 16 bytes are the tag + let tagData = ciphertextData.dropFirst(messageData.count) + + guard let sealedBox = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: messageData, tag: tagData), + let messageData = try? AES.GCM.open(sealedBox, using: symmetricKey) else { + return .failure(.decodingError) + } + + return .success(messageData) + } + } + + // MARK: - Private + + private func generateSymmetricKeyFrom(sharedSecret: SharedSecret) -> SymmetricKey { + // MSC3903 asks for a 8 zero byte salt when deriving the keys + let salt = Data(repeating: 0, count: 8) + return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(), outputByteCount: 32) + } + + private func generateRandomData(ofLength length: Int) -> Data { + var data = Data(count: length) + _ = data.withUnsafeMutableBytes { pointer -> Int32 in + if let baseAddress = pointer.baseAddress { + return SecRandomCopyBytes(kSecRandomDefault, length, baseAddress) + } + + return 0 + } + + return data + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift new file mode 100644 index 0000000000..40b7db2cb1 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -0,0 +1,146 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +class RendezvousTransport: RendezvousTransportProtocol { + private let baseURL: URL + + private var currentEtag: String? + + private(set) var rendezvousURL: URL? { + didSet { + self.currentEtag = nil + } + } + + init(baseURL: URL, rendezvousURL: URL? = nil) { + self.baseURL = baseURL + self.rendezvousURL = rendezvousURL + } + + func get() async -> Result { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + // Keep trying until resource changed + while true { + var request = URLRequest(url: url) + request.httpMethod = "GET" + + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + if let etag = currentEtag { + request.addValue(etag, forHTTPHeaderField: "If-None-Match") + } + + // Newer swift concurrency api unavailable due to iOS 14 support + let result: Result = await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, error in + guard let data = data, + let response = response, + let httpURLResponse = response as? HTTPURLResponse else { + continuation.resume(returning: .failure(.networkError)) + return + } + + // Return empty data from here if unchanged so that the external while can continue + if httpURLResponse.statusCode == 404 { + continuation.resume(returning: .failure(.rendezvousCancelled)) + } else if httpURLResponse.statusCode == 304 { + continuation.resume(returning: .success(nil)) + } else if httpURLResponse.statusCode == 200 { + // The resouce changed, update the etag + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag + } + + continuation.resume(returning: .success(data)) + } + }.resume() + } + + switch result { + case .failure(let error): + return .failure(error) + case .success(let data): + guard let data = data else { + continue + } + + return .success(data) + } + } + } + + func create(body: T) async -> Result<(), RendezvousTransportError> { + switch await send(body: body, url: baseURL, usingMethod: "POST") { + case .failure(let error): + return .failure(error) + case .success(let response): + guard let rendezvousIdentifier = response.allHeaderFields["Location"] as? String else { + return .failure(.networkError) + } + + rendezvousURL = baseURL.appendingPathComponent(rendezvousIdentifier) + + return .success(()) + } + } + + func send(body: T) async -> Result<(), RendezvousTransportError> { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + switch await send(body: body, url: url, usingMethod: "PUT") { + case .failure(let error): + return .failure(error) + case .success: + return .success(()) + } + } + + // MARK: - Private + + private func send(body: T, url: URL, usingMethod method: String) async -> Result { + guard let body = try? JSONEncoder().encode(body) else { + return .failure(.encodingError) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + request.httpBody = body + + return await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { data, response, error in + guard let httpURLResponse = response as? HTTPURLResponse else { + continuation.resume(returning: .failure(.networkError)) + return + } + + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag + } + + continuation.resume(returning: .success(httpURLResponse)) + }.resume() + } + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift new file mode 100644 index 0000000000..4c608ace83 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -0,0 +1,43 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum RendezvousTransportError: Error { + case rendezvousURLInvalid + case encodingError + case networkError + case rendezvousCancelled +} + +/// HTTP based MSC3886 channel implementation +@MainActor +protocol RendezvousTransportProtocol { + /// The current rendezvous endpoint. + /// Automatically assigned after a successful creation + var rendezvousURL: URL? { get } + + /// Creates a new rendezvous point containing the body + /// - Parameter body: arbitrary data to publish on the rendevous + /// - Returns:a transport error in case of failure + func create(body: T) async -> Result<(), RendezvousTransportError> + + /// Waits for and returns newly availalbe rendezvous data + func get() async -> Result + + /// Publishes new rendezvous data + func send(body: T) async -> Result<(), RendezvousTransportError> +} diff --git a/RiotTests/RendezvousServiceTests.swift b/RiotTests/RendezvousServiceTests.swift new file mode 100644 index 0000000000..cd3a9b0ddd --- /dev/null +++ b/RiotTests/RendezvousServiceTests.swift @@ -0,0 +1,62 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Element + +@MainActor +class RendezvousServiceTests: XCTestCase { + func testEnd2End() async { + let mockTransport = MockRendezvousTransport() + + let aliceService = RendezvousService(transport: mockTransport) + + guard case .success = await aliceService.createRendezvous() else { + XCTFail("Rendezvous creation failed") + return + } + + XCTAssertNotNil(mockTransport.rendezvousURL) + + let bobService = RendezvousService(transport: mockTransport) + + guard case .success = await bobService.joinRendezvous() else { + XCTFail("Bob failed to join") + return + } + + guard case .success = await aliceService.waitForInterlocutor() else { + XCTFail("Alice failed to establish connection") + return + } + + guard let messageData = "Hello from alice".data(using: .utf8) else { + fatalError() + } + + guard case .success = await aliceService.send(data: messageData) else { + XCTFail("Alice failed to send message") + return + } + + guard case .success(let data) = await bobService.receive() else { + XCTFail("Bob failed to receive message") + return + } + + XCTAssertEqual(messageData, data) + } +} diff --git a/changelog.d/pr-6806.feature b/changelog.d/pr-6806.feature new file mode 100644 index 0000000000..a5308aeef3 --- /dev/null +++ b/changelog.d/pr-6806.feature @@ -0,0 +1 @@ +Added RendezvousService and secure channel establishment implementation \ No newline at end of file