From dcc9cf71c6fdfa2c0c7390dd58e6ef649b6897f0 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 11 Oct 2022 15:56:37 +0300 Subject: [PATCH] Implement login with scanned QR code flows --- .../AuthenticationCoordinator.swift | 3 + .../Rendezvous/MockRendezvousTransport.swift | 4 + .../Modules/Rendezvous/RendezvousModels.swift | 38 ----- .../Rendezvous/RendezvousService.swift | 141 ++++++++++++---- .../Rendezvous/RendezvousTransport.swift | 67 ++++++-- .../RendezvousTransportProtocol.swift | 3 + .../MatrixSDK/AuthenticationService.swift | 2 +- .../AuthenticationLoginCoordinator.swift | 11 +- .../QRLogin/Common/Models/QRLoginCode.swift | 83 ++++++++-- .../Service/MatrixSDK/QRLoginService.swift | 153 ++++++++++++++++-- .../Service/Mock/MockQRLoginService.swift | 14 +- .../Service/QRLoginServiceProtocol.swift | 4 +- .../AuthenticationQRLoginConfirmScreen.swift | 10 +- ...henticationQRLoginLoadingScreenState.swift | 2 +- ...AuthenticationQRLoginScanCoordinator.swift | 6 +- ...uthenticationQRLoginStartCoordinator.swift | 21 ++- .../AuthenticationQRLoginStartScreen.swift | 6 +- .../View/UserSessionsOverview.swift | 8 +- RiotTests/RendezvousServiceTests.swift | 5 +- 19 files changed, 442 insertions(+), 139 deletions(-) delete mode 100644 Riot/Modules/Rendezvous/RendezvousModels.swift diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 19316a4a1..0d3d67120 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -336,6 +336,9 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc password = loginPassword authenticationType = .password onSessionCreated(session: session, flow: .login) + case .loggedInWithQRCode(let session): + authenticationType = .other + onSessionCreated(session: session, flow: .login) case .fallback: showFallback(for: .login) } diff --git a/Riot/Modules/Rendezvous/MockRendezvousTransport.swift b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift index 2761ea989..bdea728f6 100644 --- a/Riot/Modules/Rendezvous/MockRendezvousTransport.swift +++ b/Riot/Modules/Rendezvous/MockRendezvousTransport.swift @@ -54,4 +54,8 @@ class MockRendezvousTransport: RendezvousTransportProtocol { return .success(()) } + + func tearDown() async -> Result<(), RendezvousTransportError> { + return .success(()) + } } diff --git a/Riot/Modules/Rendezvous/RendezvousModels.swift b/Riot/Modules/Rendezvous/RendezvousModels.swift deleted file mode 100644 index 24edbf1cf..000000000 --- a/Riot/Modules/Rendezvous/RendezvousModels.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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 index 84583a583..1904b831d 100644 --- a/Riot/Modules/Rendezvous/RendezvousService.swift +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -17,6 +17,7 @@ import Foundation import CryptoKit import Combine +import MatrixSDK enum RendezvousServiceError: Error { case invalidInterlocutorKey @@ -35,33 +36,41 @@ enum RendezvousChannelAlgorithm: String { @MainActor class RendezvousService { private let transport: RendezvousTransportProtocol - private let privateKey: Curve25519.KeyAgreement.PrivateKey + private var 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) + func createRendezvous() async -> Result { + privateKey = Curve25519.KeyAgreement.PrivateKey() - switch await transport.create(body: payload) { + let publicKeyString = privateKey.publicKey.rawRepresentation.base64EncodedString() + let details = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue) + + switch await transport.create(body: details) { case .failure(let transportError): return .failure(.transportError(transportError)) case .success: - return .success(()) + guard let rendezvousURL = transport.rendezvousURL else { + return .failure(.transportError(.rendezvousURLInvalid)) + } + + let fullDetails = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, + transport: RendezvousTransportDetails(type: "http.v1", + uri: rendezvousURL.absoluteString), + key: publicKeyString) + return .success(fullDetails) } } /// 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> { + func waitForInterlocutor() async -> Result { switch await transport.get() { case .failure(let error): return .failure(.transportError(error)) @@ -70,7 +79,8 @@ class RendezvousService { return .failure(.decodingError) } - guard let interlocutorPublicKeyData = Data(base64Encoded: response.key), + guard let key = response.key, + let interlocutorPublicKeyData = Data(base64Encoded: key), let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { return .failure(.invalidInterlocutorKey) } @@ -81,31 +91,31 @@ class RendezvousService { return .failure(.internalError) } - self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret) + self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: privateKey.publicKey, + recipientPublicKey: interlocutorPublicKey) - return .success(()) + let validationCode = generateValidationCodeFrom(symmetricKey: generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: privateKey.publicKey, + recipientPublicKey: interlocutorPublicKey, + byteCount: 5)) + + return .success(validationCode) } } /// 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), + func joinRendezvous(withInterlocutorPublicKey: String) async -> Result { + guard let interlocutorPublicKeyData = Data(base64Encoded: withInterlocutorPublicKey), let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else { + MXLog.debug("[RendezvousService] Invalid interlocutor data") return .failure(.invalidInterlocutorKey) } - self.interlocutorPublicKey = interlocutorPublicKey + privateKey = Curve25519.KeyAgreement.PrivateKey() - let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() + let publicKeyString = privateKey.publicKey.rawRepresentation.base64EncodedString() let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, key: publicKeyString) @@ -113,14 +123,44 @@ class RendezvousService { return .failure(.internalError) } - // Channel established + // Wait for interlocutor acknowledgement + // Retrieve its public key and check it matches the one from the QR code above + switch await transport.get() { + case .failure(let error): + MXLog.debug("[RendezvousService] Failed waiting for interlocutor with error: \(error)") + return .failure(.transportError(error)) + case .success(let data): + guard let response = try? JSONDecoder().decode(RendezvousDetails.self, from: data) else { + MXLog.debug("[RendezvousService] Invalid interlocutor response \(String(describing: String(data: data, encoding: .utf8)))") + return .failure(.invalidInterlocutorKey) + } + + guard let key = response.key, + let interlocutorPublicKeyData = Data(base64Encoded: key), + let receivedInterlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData), + receivedInterlocutorPublicKey.rawRepresentation == interlocutorPublicKey.rawRepresentation else { + MXLog.debug("[RendezvousService] Invalid interlocutor data \(response)") + return .failure(.invalidInterlocutorKey) + } + } + + self.interlocutorPublicKey = interlocutorPublicKey + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: interlocutorPublicKey) else { + MXLog.debug("[RendezvousService] Couldn't create shared secret") return .failure(.internalError) } - self.symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret) + symmetricKey = generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: interlocutorPublicKey, + recipientPublicKey: privateKey.publicKey) - return .success(()) + let validationCode = generateValidationCodeFrom(symmetricKey: generateSymmetricKeyFrom(sharedSecret: sharedSecret, + initiatorPublicKey: interlocutorPublicKey, + recipientPublicKey: privateKey.publicKey, + byteCount: 5)) + + return .success(validationCode) } /// Send arbitrary data over the secure channel @@ -170,6 +210,8 @@ class RendezvousService { return .failure(.decodingError) } + MXLog.debug("Received rendezvous response: \(response)") + guard let ciphertextData = Data(base64Encoded: response.ciphertext), let nonceData = Data(base64Encoded: response.iv), let nonce = try? AES.GCM.Nonce(data: nonceData) else { @@ -189,12 +231,53 @@ class RendezvousService { } } + func tearDown() async -> Result<(), RendezvousServiceError> { + switch await transport.tearDown() { + case .failure(let error): + return .failure(.transportError(error)) + case .success: + privateKey = nil + interlocutorPublicKey = nil + symmetricKey = nil + + return .success(()) + } + } + // MARK: - Private - private func generateSymmetricKeyFrom(sharedSecret: SharedSecret) -> SymmetricKey { + private func generateValidationCodeFrom(symmetricKey: SymmetricKey) -> String { + let bytes = symmetricKey.withUnsafeBytes { + return Data(Array($0)) + }.map { UInt($0) } + + let first = (bytes[0] << 5 | bytes[1] >> 3) + 1000 + let secondPart1 = UInt(bytes[1] & 0x7) << 10 + let secondPart2 = bytes[2] << 2 | bytes[3] >> 6 + let second = (secondPart1 | secondPart2) + 1000 + let third = ((bytes[3] & 0x3f) << 7 | bytes[4] >> 1) + 1000 + + return "\(first)-\(second)-\(third)" + } + + private func generateSymmetricKeyFrom(sharedSecret: SharedSecret, + initiatorPublicKey: Curve25519.KeyAgreement.PublicKey, + recipientPublicKey: Curve25519.KeyAgreement.PublicKey, + byteCount: Int = SHA256Digest.byteCount) -> SymmetricKey { + guard let sharedInfoData = [RendezvousChannelAlgorithm.ECDH_V1.rawValue, + initiatorPublicKey.rawRepresentation.base64EncodedString(), + recipientPublicKey.rawRepresentation.base64EncodedString()] + .joined(separator: "|") + .data(using: .utf8) else { + fatalError("[RendezvousService] Failed creating symmetric key shared data") + } + // 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) + return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, + salt: salt, + sharedInfo: sharedInfoData, + outputByteCount: byteCount) } private func generateRandomData(ofLength length: Int) -> Data { diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift index 40b7db2cb..266b02138 100644 --- a/Riot/Modules/Rendezvous/RendezvousTransport.swift +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -15,6 +15,7 @@ // import Foundation +import MatrixSDK class RendezvousTransport: RendezvousTransportProtocol { private let baseURL: URL @@ -33,12 +34,14 @@ class RendezvousTransport: RendezvousTransportProtocol { } func get() async -> Result { - guard let url = rendezvousURL else { - return .failure(.rendezvousURLInvalid) - } - // Keep trying until resource changed while true { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + MXLog.debug("[RendezvousTransport] polling \(url) after etag: \(String(describing: currentEtag))") + var request = URLRequest(url: url) request.httpMethod = "GET" @@ -50,8 +53,8 @@ class RendezvousTransport: RendezvousTransportProtocol { // 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, + guard error == nil, + let data = data, let httpURLResponse = response as? HTTPURLResponse else { continuation.resume(returning: .failure(.networkError)) return @@ -59,8 +62,10 @@ class RendezvousTransport: RendezvousTransportProtocol { // Return empty data from here if unchanged so that the external while can continue if httpURLResponse.statusCode == 404 { + MXLog.warning("[RendezvousTransport] Rendezvous no longer available available") continuation.resume(returning: .failure(.rendezvousCancelled)) } else if httpURLResponse.statusCode == 304 { + MXLog.debug("[RendezvousTransport] Rendezvous unchanged") continuation.resume(returning: .success(nil)) } else if httpURLResponse.statusCode == 200 { // The resouce changed, update the etag @@ -68,9 +73,12 @@ class RendezvousTransport: RendezvousTransportProtocol { self.currentEtag = etag } + MXLog.debug("[RendezvousTransport] Received update") + continuation.resume(returning: .success(data)) } - }.resume() + } + .resume() } switch result { @@ -78,6 +86,9 @@ class RendezvousTransport: RendezvousTransportProtocol { return .failure(error) case .success(let data): guard let data = data else { + // Avoid making too many requests. Sleep for one second before the next attempt + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue } @@ -114,10 +125,35 @@ class RendezvousTransport: RendezvousTransportProtocol { } } + func tearDown() async -> Result<(), RendezvousTransportError> { + guard let url = rendezvousURL else { + return .failure(.rendezvousURLInvalid) + } + + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + return await withCheckedContinuation { continuation in + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard error == nil, response as? HTTPURLResponse != nil else { + MXLog.warning("[RendezvousTransport] Failed tearing down rendezvous with error: \(String(describing: error))") + continuation.resume(returning: .failure(.networkError)) + return + } + + MXLog.debug("[RendezvousTransport] Tore down rendezvous at URL: \(url)") + + self?.rendezvousURL = nil + + continuation.resume(returning: .success(())) + } + .resume() + } + } + // MARK: - Private private func send(body: T, url: URL, usingMethod method: String) async -> Result { - guard let body = try? JSONEncoder().encode(body) else { + guard let bodyData = try? JSONEncoder().encode(body) else { return .failure(.encodingError) } @@ -126,11 +162,17 @@ class RendezvousTransport: RendezvousTransportProtocol { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = body + request.httpBody = bodyData + + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + if let etag = currentEtag { + request.addValue(etag, forHTTPHeaderField: "If-Match") + } return await withCheckedContinuation { continuation in URLSession.shared.dataTask(with: request) { data, response, error in - guard let httpURLResponse = response as? HTTPURLResponse else { + guard error == nil, let httpURLResponse = response as? HTTPURLResponse else { + MXLog.warning("[RendezvousTransport] Failed sending data with error: \(String(describing: error))") continuation.resume(returning: .failure(.networkError)) return } @@ -139,8 +181,11 @@ class RendezvousTransport: RendezvousTransportProtocol { self.currentEtag = etag } + MXLog.debug("[RendezvousTransport] Sent data: \(body)") + continuation.resume(returning: .success(httpURLResponse)) - }.resume() + } + .resume() } } } diff --git a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift index 4c608ace8..82a8be0d0 100644 --- a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -40,4 +40,7 @@ protocol RendezvousTransportProtocol { /// Publishes new rendezvous data func send(body: T) async -> Result<(), RendezvousTransportError> + + /// Deletes the resource at the current rendezvous endpoint + func tearDown() async -> Result<(), RendezvousTransportError> } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 9df083fb4..9be77dc00 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -259,7 +259,7 @@ class AuthenticationService: NSObject { } let loginFlow = try await getLoginFlowResult(client: client) - + let supportsQRLogin = try await QRLoginService(client: client, mode: .notAuthenticated).isServiceAvailable() diff --git a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift index 4a45130ea..c4dac49a3 100644 --- a/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Login/Coordinator/AuthenticationLoginCoordinator.swift @@ -30,6 +30,8 @@ enum AuthenticationLoginCoordinatorResult: CustomStringConvertible { case continueWithSSO(SSOIdentityProvider) /// Login was successful with the associated session created. case success(session: MXSession, password: String) + /// Login was successful with the associated session created. + case loggedInWithQRCode(session: MXSession) /// Login requested a fallback case fallback @@ -40,6 +42,8 @@ enum AuthenticationLoginCoordinatorResult: CustomStringConvertible { return "continueWithSSO: \(provider)" case .success: return "success" + case .loggedInWithQRCode: + return "loggedInWithQRCode" case .fallback: return "fallback" } @@ -294,8 +298,13 @@ final class AuthenticationLoginCoordinator: Coordinator, Presentable { let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter, qrLoginService: service) let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters) - coordinator.callback = { [weak self, weak coordinator] _ in + coordinator.callback = { [weak self, weak coordinator] callback in guard let self = self, let coordinator = coordinator else { return } + switch callback { + case .done(let session): + self.callback?(.loggedInWithQRCode(session: session)) + } + self.remove(childCoordinator: coordinator) } diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift index ce28652d2..8e7234a61 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Models/QRLoginCode.swift @@ -17,23 +17,78 @@ import Foundation struct QRLoginCode: Codable { - var user: String? - var initiator: QRLoginDataInitiatorDevice? - var rendezvous: QRLoginRendezvous? + let rendezvous: RendezvousDetails + let intent: String } -enum QRLoginDataInitiatorDevice: String, Codable { - case new = "new_device" - case existing = "existing_device" -} - -struct QRLoginRendezvous: Codable { - var transport: QRLoginRendezvousTransportDetails - var algorithm: String? +struct RendezvousDetails: Codable { + let algorithm: String + var transport: RendezvousTransportDetails? var key: String? } -struct QRLoginRendezvousTransportDetails: Codable { - var type: String - var uri: String? +struct RendezvousTransportDetails: Codable { + let type: String + let uri: String +} + +struct RendezvousMessage: Codable { + let iv: String + let ciphertext: String +} + +struct QRLoginRendezvousPayload: Codable { + let type: `Type` + + var intent: Intent? + var outcome: Outcome? + + var protocols: [`Protocol`]? + var `protocol`: `Protocol`? + + var homeserver: String? + var user: String? + var loginToken: String? + var deviceId: String? + var deviceKey: String? + + var verifyingDeviceId: String? + var verifyingDeviceKey: String? + + var masterKey: String? + + enum CodingKeys: String, CodingKey { + case type + case intent + case outcome + case homeserver + case user + case protocols + case `protocol` + case loginToken = "login_token" + case deviceId = "device_id" + case deviceKey = "device_key" + case verifyingDeviceId = "verifying_device_id" + case verifyingDeviceKey = "verifying_device_key" + case masterKey = "master_key" + } + + enum `Type`: String, Codable { + case loginStart = "m.login.start" + case loginProgress = "m.login.progress" + case loginFinish = "m.login.finish" + } + + enum Intent: String, Codable { + case loginStart = "login.start" + } + + enum Outcome: String, Codable { + case success = "success" + case declined = "declined" + } + + enum `Protocol`: String, Codable { + case loginToken = "login_token" + } } diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift index 520b85531..4939231ef 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/MatrixSDK/QRLoginService.swift @@ -25,15 +25,19 @@ import ZXingObjC class QRLoginService: NSObject, QRLoginServiceProtocol { private let client: AuthenticationRestClient + private let sessionCreator: SessionCreatorProtocol private var isCameraReady = false private lazy var zxCapture = ZXCapture() private let cameraAccessManager = CameraAccessManager() + + private var rendezvousService: RendezvousService? init(client: AuthenticationRestClient, mode: QRLoginServiceMode, state: QRLoginServiceState = .initial) { self.client = client + self.sessionCreator = SessionCreator() self.mode = mode self.state = state super.init() @@ -72,16 +76,9 @@ class QRLoginService: NSObject, QRLoginServiceProtocol { } func generateQRCode() async throws -> QRLoginCode { - let transport = QRLoginRendezvousTransportDetails(type: "http.v1", - uri: "") - let rendezvous = QRLoginRendezvous(transport: transport, - algorithm: "m.rendezvous.v1.curve25519-aes-sha256", - key: "") - return QRLoginCode(user: client.credentials.userId, - initiator: .new, - rendezvous: rendezvous) + fatalError("Not implemented") } - + func scannerView() -> AnyView { let frame = UIScreen.main.bounds let view = UIView(frame: frame) @@ -109,6 +106,8 @@ class QRLoginService: NSObject, QRLoginServiceProtocol { } func stopScanning(destroy: Bool) { + zxCapture.delegate = nil + guard zxCapture.running else { return } @@ -120,20 +119,21 @@ class QRLoginService: NSObject, QRLoginServiceProtocol { } } + @MainActor func processScannedQR(_ data: Data) { - state = .connectingToDevice - do { - let code = try JSONDecoder().decode(QRLoginCode.self, from: data) - MXLog.debug("[QRLoginService] processScannedQR: \(code)") - // TODO: implement - } catch { + guard let code = try? JSONDecoder().decode(QRLoginCode.self, from: data) else { state = .failed(error: .invalidQR) + return + } + + Task { + await processQRLoginCode(code) } } func confirmCode() { switch state { - case .waitingForConfirmation(let code): + case .waitingForConfirmation: // TODO: implement break default: @@ -143,11 +143,19 @@ class QRLoginService: NSObject, QRLoginServiceProtocol { func restart() { state = .initial + + Task { + await declineRendezvous() + } } func reset() { stopScanning(destroy: false) state = .initial + + Task { + await declineRendezvous() + } } deinit { @@ -155,6 +163,119 @@ class QRLoginService: NSObject, QRLoginServiceProtocol { } // MARK: Private + + @MainActor + private func processQRLoginCode(_ code: QRLoginCode) async { + MXLog.debug("[QRLoginService] processQRLoginCode: \(code)") + state = .connectingToDevice + + guard let uri = code.rendezvous.transport?.uri, + let rendezvousURL = URL(string: uri), + let key = code.rendezvous.key else { + MXLog.debug("[QRLoginService] QR code invalid") + state = .failed(error: .invalidQR) + return + } + + let transport = RendezvousTransport(baseURL: BuildSettings.rendezvousServerBaseURL, + rendezvousURL: rendezvousURL) + let rendezvousService = RendezvousService(transport: transport) + self.rendezvousService = rendezvousService + + MXLog.debug("[QRLoginService] Joining the rendezvous at \(rendezvousURL)") + guard case .success(let validationCode) = await rendezvousService.joinRendezvous(withInterlocutorPublicKey: key) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + state = .waitingForConfirmation(validationCode) + + MXLog.debug("[QRLoginService] Requesting login") + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginStart, intent: .loginStart)), + case .success = await rendezvousService.send(data: requestData) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Waiting for available protocols") + guard case let .success(data) = await rendezvousService.receive(), + let responsePayload = try? JSONDecoder().decode(QRLoginRendezvousPayload.self, from: data) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Received available protocols \(responsePayload)") + guard let protocols = responsePayload.protocols, + protocols.contains(.loginToken) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + MXLog.debug("[QRLoginService] Request login with `login_token`") + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginProgress, protocol: .loginToken)), + case .success = await rendezvousService.send(data: requestData) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + state = .waitingForRemoteSignIn + + MXLog.debug("[QRLoginService] Waiting for the login token") + guard case let .success(data) = await rendezvousService.receive(), + let responsePayload = try? JSONDecoder().decode(QRLoginRendezvousPayload.self, from: data), + let login_token = responsePayload.loginToken else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + MXLog.debug("[QRLoginService] Received login token \(responsePayload)") + + MXLog.debug("[QRLoginService] Logging in with the login token") + guard let credentials = try? await client.login(parameters: LoginTokenParameters(token: login_token)) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + MXLog.debug("[QRLoginService] Got acess token") + + let session = sessionCreator.createSession(credentials: credentials, client: client, removeOtherAccounts: false) + + MXLog.debug("[QRLoginService] Created session") + + MXLog.debug("[QRLoginService] No E2EE support. Inform the interlocutor of finishing") + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginFinish, outcome: .success)), + case .success = await rendezvousService.send(data: requestData) else { + await teardownRendezvous(state: .failed(error: .rendezvousFailed)) + return + } + + state = .completed(session: session) + } + + private func declineRendezvous() async { + guard let requestData = try? JSONEncoder().encode(QRLoginRendezvousPayload(type: .loginFinish, outcome: .declined)) else { + return + } + + _ = await rendezvousService?.send(data: requestData) + + await teardownRendezvous() + } + + private func teardownRendezvous(state: QRLoginServiceState? = nil) async { + // Stop listening for changes, try deleting the resource + _ = await rendezvousService?.tearDown() + + // Try setting the new state, if necessary + if let state = state { + switch self.state { + case .completed: + return + case .initial: + return + default: + self.state = state + } + } + } } // MARK: - ZXCaptureDelegate diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift index 6ad4393bb..3ea0242e3 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/Mock/MockQRLoginService.swift @@ -52,14 +52,12 @@ class MockQRLoginService: QRLoginServiceProtocol { } func generateQRCode() async throws -> QRLoginCode { - let transport = QRLoginRendezvousTransportDetails(type: "http.v1", - uri: "https://matrix.org") - let rendezvous = QRLoginRendezvous(transport: transport, - algorithm: "m.rendezvous.v1.curve25519-aes-sha256", - key: "") - return QRLoginCode(user: "@mock:matrix.org", - initiator: .new, - rendezvous: rendezvous) + let details = RendezvousDetails(algorithm: "m.rendezvous.v1.curve25519-aes-sha256", + transport: .init(type: "http.v1", + uri: "https://matrix.org"), + key: "some.public.key") + return QRLoginCode(rendezvous: details, + intent: "login.start") } func scannerView() -> AnyView { diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift index ee9ff8c5e..94464bdbb 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Common/Service/QRLoginServiceProtocol.swift @@ -33,6 +33,7 @@ enum QRLoginServiceError: Error, Equatable { case invalidQR case requestDenied case requestTimedOut + case rendezvousFailed } // MARK: - QRLoginServiceState @@ -44,7 +45,8 @@ enum QRLoginServiceState: Equatable { case waitingForConfirmation(_ code: String) case waitingForRemoteSignIn case failed(error: QRLoginServiceError) - case completed + // This is really an MXSession but that would break RiotSwiftUI + case completed(session: Any) static func == (lhs: QRLoginServiceState, rhs: QRLoginServiceState) -> Bool { switch (lhs, rhs) { diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift index 2011d5df6..b0ddb0906 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Confirm/View/AuthenticationQRLoginConfirmScreen.swift @@ -94,11 +94,11 @@ struct AuthenticationQRLoginConfirmScreen: View { .padding(.bottom, 12) .accessibilityIdentifier("alertText") - Button(action: confirm) { - Text(VectorL10n.confirm) - } - .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) - .accessibilityIdentifier("confirmButton") +// Button(action: confirm) { +// Text(VectorL10n.confirm) +// } +// .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) +// .accessibilityIdentifier("confirmButton") Button(action: cancel) { Text(VectorL10n.cancel) diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift index 6bf6cbab6..fc0acbd45 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Loading/MockAuthenticationQRLoginLoadingScreenState.swift @@ -48,7 +48,7 @@ enum MockAuthenticationQRLoginLoadingScreenState: MockScreenState, CaseIterable case .waitingForRemoteSignIn: viewModel = .init(qrLoginService: MockQRLoginService(withState: .waitingForRemoteSignIn)) case .completed: - viewModel = .init(qrLoginService: MockQRLoginService(withState: .completed)) + viewModel = .init(qrLoginService: MockQRLoginService(withState: .completed(session: ""))) } // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift index 15ec5c728..ea1269b57 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Scan/Coordinator/AuthenticationQRLoginScanCoordinator.swift @@ -16,6 +16,7 @@ import CommonKit import SwiftUI +import MatrixSDK struct AuthenticationQRLoginScanCoordinatorParameters { let navigationRouter: NavigationRouterType @@ -78,7 +79,10 @@ final class AuthenticationQRLoginScanCoordinator: Coordinator, Presentable { self.showDisplayQRScreen() case .qrScanned(let data): self.qrLoginService.stopScanning(destroy: false) - self.qrLoginService.processScannedQR(data) + + Task { + await self.qrLoginService.processScannedQR(data) + } } } } diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift index 3149e1121..16a05e3ae 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/Coordinator/AuthenticationQRLoginStartCoordinator.swift @@ -25,7 +25,7 @@ struct AuthenticationQRLoginStartCoordinatorParameters { enum AuthenticationQRLoginStartCoordinatorResult { /// Login with QR done - case done + case done(session: MXSession) } final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { @@ -108,18 +108,23 @@ final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { switch state { case .initial: removeAllChildren() - case .connectingToDevice, .waitingForRemoteSignIn, .completed: + case .connectingToDevice, .waitingForRemoteSignIn: showLoadingScreenIfNeeded() case .waitingForConfirmation: showConfirmationScreenIfNeeded() case .failed(let error): switch error { case .noCameraAccess, .noCameraAvailable: - // handled in scanning screen - break + break // handled in scanning screen default: showFailureScreenIfNeeded() } + case .completed(let session): + guard let session = session as? MXSession else { + showFailureScreenIfNeeded() + return + } + callback?(.done(session: session)) default: break } @@ -162,6 +167,8 @@ final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { /// Shows the display QR screen. private func showDisplayQRScreen() { MXLog.debug("[AuthenticationQRLoginStartCoordinator] showDisplayQRScreen") + + removeAllChildren(animated: false) let parameters = AuthenticationQRLoginDisplayCoordinatorParameters(navigationRouter: navigationRouter, qrLoginService: qrLoginService) @@ -182,6 +189,8 @@ final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { /// Shows the loading screen. private func showLoadingScreenIfNeeded() { MXLog.debug("[AuthenticationQRLoginStartCoordinator] showLoadingScreenIfNeeded") + + removeAllChildren(animated: false) if let lastCoordinator = childCoordinators.last, lastCoordinator is AuthenticationQRLoginLoadingCoordinator { @@ -208,6 +217,8 @@ final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { /// Shows the confirmation screen. private func showConfirmationScreenIfNeeded() { MXLog.debug("[AuthenticationQRLoginStartCoordinator] showConfirmationScreenIfNeeded") + + removeAllChildren(animated: false) if let lastCoordinator = childCoordinators.last, lastCoordinator is AuthenticationQRLoginConfirmCoordinator { @@ -234,6 +245,8 @@ final class AuthenticationQRLoginStartCoordinator: Coordinator, Presentable { /// Shows the failure screen. private func showFailureScreenIfNeeded() { MXLog.debug("[AuthenticationQRLoginStartCoordinator] showFailureScreenIfNeeded") + + removeAllChildren(animated: false) if let lastCoordinator = childCoordinators.last, lastCoordinator is AuthenticationQRLoginFailureCoordinator { diff --git a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift index 6d09cbe4f..a5025a321 100644 --- a/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/QRLogin/Start/View/AuthenticationQRLoginStartScreen.swift @@ -77,7 +77,7 @@ struct AuthenticationQRLoginStartScreen: View { .accessibilityIdentifier("subtitleLabel") } } - + /// The screen's footer. var footerContent: some View { VStack(spacing: 12) { @@ -87,10 +87,10 @@ struct AuthenticationQRLoginStartScreen: View { .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) .padding(.bottom, 8) .accessibilityIdentifier("scanQRButton") - + if context.viewState.canShowDisplayQRButton { LabelledDivider(label: VectorL10n.authenticationQrLoginStartNeedAlternative) - + Button(action: displayQR) { Text(VectorL10n.authenticationQrLoginStartDisplayQr) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index 3cb8d0b3f..83b08bee9 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -39,10 +39,10 @@ struct UserSessionsOverview: View { } .readableFrame() - if viewModel.viewState.linkDeviceButtonVisible { - linkDeviceView - .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) - } +// if viewModel.viewState.linkDeviceButtonVisible { +// linkDeviceView +// .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) +// } } } .background(theme.colors.system.ignoresSafeArea()) diff --git a/RiotTests/RendezvousServiceTests.swift b/RiotTests/RendezvousServiceTests.swift index cd3a9b0dd..1959c257a 100644 --- a/RiotTests/RendezvousServiceTests.swift +++ b/RiotTests/RendezvousServiceTests.swift @@ -24,7 +24,8 @@ class RendezvousServiceTests: XCTestCase { let aliceService = RendezvousService(transport: mockTransport) - guard case .success = await aliceService.createRendezvous() else { + guard case .success(let rendezvousDetails) = await aliceService.createRendezvous(), + let interlocutorPublicKey = rendezvousDetails.key else { XCTFail("Rendezvous creation failed") return } @@ -33,7 +34,7 @@ class RendezvousServiceTests: XCTestCase { let bobService = RendezvousService(transport: mockTransport) - guard case .success = await bobService.joinRendezvous() else { + guard case .success = await bobService.joinRendezvous(withInterlocutorPublicKey: interlocutorPublicKey) else { XCTFail("Bob failed to join") return }