diff --git a/Riot/Modules/Rendezvous/MockRendezvousTransport.swift b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift new file mode 100644 index 000000000..2761ea989 --- /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 000000000..af4dbc50f --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousModels.swift @@ -0,0 +1,37 @@ +// +// 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 combined: String +} diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift new file mode 100644 index 000000000..370703ea1 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -0,0 +1,170 @@ +// +// 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) +} + +enum RendezvousServiceCallback { + case error(RendezvousServiceError) +} + +enum RendezvousChannelAlgorithm: String { + case ECDH_V1 = "m.rendezvous.v1.x25519-aes-sha256" +} + +@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() + } + + 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(()) + } + } + + 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(()) + } + } + + 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) + } + + 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(()) + } + + func send(data: Data) async -> Result<(), RendezvousServiceError> { + guard let symmetricKey = symmetricKey else { + return .failure(.channelNotReady) + } + + guard let sealedBox = try? AES.GCM.seal(data, using: symmetricKey), + let combinedData = sealedBox.combined else { + return .failure(.internalError) + } + + let body = RendezvousMessage(combined: combinedData.base64EncodedString()) + + switch await transport.send(body: body) { + case .failure(let transportError): + return .failure(.transportError(transportError)) + case .success: + return .success(()) + } + } + + 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 combinedData = Data(base64Encoded: response.combined), + let sealedBox = try? AES.GCM.SealedBox(combined: combinedData), + let messageData = try? AES.GCM.open(sealedBox, using: symmetricKey) else { + return .failure(.decodingError) + } + + return .success(messageData) + } + } + + // MARK: - Private + + private func generateSymmetricKeyFrom(sharedSecret: SharedSecret) -> SymmetricKey { + let salt = Data(repeating: 0, count: 8) + return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(), outputByteCount: 32) + } +} diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift new file mode 100644 index 000000000..6ea923d63 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -0,0 +1,149 @@ +// +// 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 { + if httpURLResponse.allHeaderFields["Content-Type"] as? String != "application/json" { + continuation.resume(returning: .success(nil)) + } else { + 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 000000000..6aea032b4 --- /dev/null +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -0,0 +1,33 @@ +// +// 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 +} + +@MainActor +protocol RendezvousTransportProtocol { + var rendezvousURL: URL? { get } + + func create(body: T) async -> Result<(), RendezvousTransportError> + func get() async -> Result + func send(body: T) async -> Result<(), RendezvousTransportError> +} diff --git a/RiotTests/RendezvousServiceTests.swift b/RiotTests/RendezvousServiceTests.swift new file mode 100644 index 000000000..cd3a9b0dd --- /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) + } +}