mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 23:32:41 +00:00
Implement login with scanned QR code flows
This commit is contained in:
parent
be25adc21d
commit
dcc9cf71c6
19 changed files with 442 additions and 139 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -54,4 +54,8 @@ class MockRendezvousTransport: RendezvousTransportProtocol {
|
|||
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func tearDown() async -> Result<(), RendezvousTransportError> {
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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<RendezvousDetails, RendezvousServiceError> {
|
||||
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<String, RendezvousServiceError> {
|
||||
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<String, RendezvousServiceError> {
|
||||
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 {
|
||||
|
|
|
@ -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<Data, RendezvousTransportError> {
|
||||
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<Data?, RendezvousTransportError> = 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<T: Encodable>(body: T, url: URL, usingMethod method: String) async -> Result<HTTPURLResponse, RendezvousTransportError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,4 +40,7 @@ protocol RendezvousTransportProtocol {
|
|||
|
||||
/// Publishes new rendezvous data
|
||||
func send<T: Encodable>(body: T) async -> Result<(), RendezvousTransportError>
|
||||
|
||||
/// Deletes the resource at the current rendezvous endpoint
|
||||
func tearDown() async -> Result<(), RendezvousTransportError>
|
||||
}
|
||||
|
|
|
@ -259,7 +259,7 @@ class AuthenticationService: NSObject {
|
|||
}
|
||||
|
||||
let loginFlow = try await getLoginFlowResult(client: client)
|
||||
|
||||
|
||||
let supportsQRLogin = try await QRLoginService(client: client,
|
||||
mode: .notAuthenticated).isServiceAvailable()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue