Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for v2 of MSC3903 #7372

Merged
merged 4 commits into from
Feb 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Riot/Assets/en.lproj/Vector.strings
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@
"authentication_qr_login_loading_signed_in" = "You are now signed in on your other device.";

"authentication_qr_login_failure_title" = "Linking failed";
"authentication_qr_login_failure_device_not_supported" = "Linking with this device is not supported.";
"authentication_qr_login_failure_invalid_qr" = "QR code is invalid.";
"authentication_qr_login_failure_request_denied" = "The request was denied on the other device.";
"authentication_qr_login_failure_request_timed_out" = "The linking wasn’t completed in the required time.";
Expand Down
4 changes: 4 additions & 0 deletions Riot/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,10 @@ public class VectorL10n: NSObject {
public static var authenticationQrLoginDisplayTitle: String {
return VectorL10n.tr("Vector", "authentication_qr_login_display_title")
}
/// Linking with this device is not supported.
public static var authenticationQrLoginFailureDeviceNotSupported: String {
return VectorL10n.tr("Vector", "authentication_qr_login_failure_device_not_supported")
}
/// QR code is invalid.
public static var authenticationQrLoginFailureInvalidQr: String {
return VectorL10n.tr("Vector", "authentication_qr_login_failure_invalid_qr")
Expand Down
46 changes: 31 additions & 15 deletions Riot/Modules/Rendezvous/RendezvousService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum RendezvousServiceError: Error {
/// Algorithm name as per MSC3903
enum RendezvousChannelAlgorithm: String {
case ECDH_V1 = "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256"
case ECDH_V2 = "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256"
}

/// Allows communication through a secure channel. Based on MSC3886 and MSC3903
Expand All @@ -40,17 +41,20 @@ class RendezvousService {
private var privateKey: Curve25519.KeyAgreement.PrivateKey!
private var interlocutorPublicKey: Curve25519.KeyAgreement.PublicKey?
private var symmetricKey: SymmetricKey?
private var algorithm: RendezvousChannelAlgorithm

init(transport: RendezvousTransportProtocol) {
init(transport: RendezvousTransportProtocol, algorithm: RendezvousChannelAlgorithm) {
self.transport = transport
self.algorithm = algorithm
}

/// Creates a new rendezvous endpoint and publishes the creator's public key
func createRendezvous() async -> Result<RendezvousDetails, RendezvousServiceError> {
privateKey = Curve25519.KeyAgreement.PrivateKey()
let algorithm = RendezvousChannelAlgorithm.ECDH_V2

let publicKeyString = privateKey.publicKey.rawRepresentation.base64EncodedString()
let details = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue)
let publicKeyString = encodeBase64(data: privateKey.publicKey.rawRepresentation)
let details = RendezvousDetails(algorithm: algorithm.rawValue)

switch await transport.create(body: details) {
case .failure(let transportError):
Expand All @@ -60,7 +64,7 @@ class RendezvousService {
return .failure(.transportError(.rendezvousURLInvalid))
}

let fullDetails = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue,
let fullDetails = RendezvousDetails(algorithm: algorithm.rawValue,
transport: RendezvousTransportDetails(type: "org.matrix.msc3886.http.v1",
uri: rendezvousURL.absoluteString),
key: publicKeyString)
Expand All @@ -80,7 +84,7 @@ class RendezvousService {
}

guard let key = response.key,
let interlocutorPublicKeyData = Data(base64Encoded: key),
let interlocutorPublicKeyData = decodeBase64(input: key),
let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else {
return .failure(.invalidInterlocutorKey)
}
Expand All @@ -107,16 +111,16 @@ class RendezvousService {
/// 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(withPublicKey publicKey: String) async -> Result<String, RendezvousServiceError> {
guard let interlocutorPublicKeyData = Data(base64Encoded: publicKey),
guard let interlocutorPublicKeyData = decodeBase64(input: publicKey),
let interlocutorPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: interlocutorPublicKeyData) else {
MXLog.debug("[RendezvousService] Invalid interlocutor data")
return .failure(.invalidInterlocutorKey)
}

privateKey = Curve25519.KeyAgreement.PrivateKey()

let publicKeyString = privateKey.publicKey.rawRepresentation.base64EncodedString()
let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue,
let publicKeyString = encodeBase64(data: privateKey.publicKey.rawRepresentation)
let payload = RendezvousDetails(algorithm: algorithm.rawValue,
key: publicKeyString)

guard case .success = await transport.send(body: payload) else {
Expand All @@ -142,6 +146,18 @@ class RendezvousService {
return .success(validationCode)
}

private func encodeBase64(data: Data) -> String {
if algorithm == .ECDH_V2 {
return MXBase64Tools.unpaddedBase64(from: data)
}
return MXBase64Tools.base64(from: data)
}

private func decodeBase64(input: String) -> Data? {
// MXBase64Tools will decode both padded and unpadded data so we don't need to take account of algorithm here
return MXBase64Tools.data(fromBase64: input)
}

/// Send arbitrary data over the secure channel
/// This will use the previously generated symmetric key to AES encrypt the payload
/// - Parameter data: the data to be encrypted and sent
Expand All @@ -162,8 +178,8 @@ class RendezvousService {
var ciphertext = sealedBox.ciphertext
ciphertext.append(contentsOf: sealedBox.tag)

let body = RendezvousMessage(iv: Data(nonce).base64EncodedString(),
ciphertext: ciphertext.base64EncodedString())
let body = RendezvousMessage(iv: encodeBase64(data: Data(nonce)),
ciphertext: encodeBase64(data: ciphertext))

switch await transport.send(body: body) {
case .failure(let transportError):
Expand Down Expand Up @@ -191,8 +207,8 @@ class RendezvousService {

MXLog.debug("Received rendezvous response: \(response)")

guard let ciphertextData = Data(base64Encoded: response.ciphertext),
let nonceData = Data(base64Encoded: response.iv),
guard let ciphertextData = decodeBase64(input: response.ciphertext),
let nonceData = decodeBase64(input: response.iv),
let nonce = try? AES.GCM.Nonce(data: nonceData) else {
return .failure(.decodingError)
}
Expand Down Expand Up @@ -243,9 +259,9 @@ class RendezvousService {
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()]
guard let sharedInfoData = [algorithm.rawValue,
encodeBase64(data: initiatorPublicKey.rawRepresentation),
encodeBase64(data: recipientPublicKey.rawRepresentation)]
.joined(separator: "|")
.data(using: .utf8) else {
fatalError("[RendezvousService] Failed creating symmetric key shared data")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,16 @@ class QRLoginService: NSObject, QRLoginServiceProtocol {
@MainActor
private func processQRLoginCode(_ code: QRLoginCode) async {
MXLog.debug("[QRLoginService] processQRLoginCode: \(code)")
state = .connectingToDevice


// we check these first so that we can show a more specific error message
guard code.rendezvous.transport?.type == "org.matrix.msc3886.http.v1",
let algorithm = RendezvousChannelAlgorithm(rawValue: code.rendezvous.algorithm) else {
MXLog.error("[QRLoginService] Unsupported algorithm or transport")
state = .failed(error: .deviceNotSupported)
return
}

// so, this is of an expected algorithm so any bad data can be considered an invalid QR code
guard code.intent == QRLoginRendezvousPayload.Intent.loginReciprocate.rawValue,
let uri = code.rendezvous.transport?.uri,
let rendezvousURL = URL(string: uri),
Expand All @@ -182,9 +190,11 @@ class QRLoginService: NSObject, QRLoginServiceProtocol {
return
}

state = .connectingToDevice

let transport = RendezvousTransport(baseURL: BuildSettings.rendezvousServerBaseURL,
rendezvousURL: rendezvousURL)
let rendezvousService = RendezvousService(transport: transport)
let rendezvousService = RendezvousService(transport: transport, algorithm: algorithm)
self.rendezvousService = rendezvousService

MXLog.debug("[QRLoginService] Joining the rendezvous at \(rendezvousURL)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum QRLoginServiceMode {
enum QRLoginServiceError: Error, Equatable {
case noCameraAccess
case noCameraAvailable
case deviceNotSupported
case invalidQR
case requestDenied
case requestTimedOut
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ class AuthenticationQRLoginFailureViewModel: AuthenticationQRLoginFailureViewMod
case .invalidQR:
self.state.failureText = VectorL10n.authenticationQrLoginFailureInvalidQr
self.state.retryButtonVisible = true
case .deviceNotSupported:
self.state.failureText = VectorL10n.authenticationQrLoginFailureDeviceNotSupported
self.state.retryButtonVisible = true
case .requestDenied:
self.state.failureText = VectorL10n.authenticationQrLoginFailureRequestDenied
self.state.retryButtonVisible = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable
// with specific, minimal associated data that will allow you
// mock that screen.
case invalidQR
case deviceNotSupported
case requestDenied
case requestTimedOut

Expand All @@ -35,7 +36,7 @@ enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable
/// A list of screen state definitions
static var allCases: [MockAuthenticationQRLoginFailureScreenState] {
// Each of the presence statuses
[.invalidQR, .requestDenied, .requestTimedOut]
[.invalidQR, .deviceNotSupported, .requestDenied, .requestTimedOut]
}

/// Generate the view struct for the screen state.
Expand All @@ -45,6 +46,8 @@ enum MockAuthenticationQRLoginFailureScreenState: MockScreenState, CaseIterable
switch self {
case .invalidQR:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .invalidQR)))
case .deviceNotSupported:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .deviceNotSupported)))
case .requestDenied:
viewModel = .init(qrLoginService: MockQRLoginService(withState: .failed(error: .requestDenied)))
case .requestTimedOut:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ class AuthenticationQRLoginFailureUITests: MockScreenTestCase {
XCTAssertTrue(cancelButton.isEnabled)
}

func testDeviceNotSupported() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.deviceNotSupported.title)

XCTAssertTrue(app.staticTexts["failureLabel"].exists)

let retryButton = app.buttons["retryButton"]
XCTAssertTrue(retryButton.exists)
XCTAssertTrue(retryButton.isEnabled)

let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
XCTAssertTrue(cancelButton.isEnabled)
}

func testRequestDenied() {
app.goToScreenWithIdentifier(MockAuthenticationQRLoginFailureScreenState.requestDenied.title)

Expand Down
48 changes: 45 additions & 3 deletions RiotTests/RendezvousServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ import XCTest

@MainActor
class RendezvousServiceTests: XCTestCase {
func testEnd2End() async {
func testEnd2EndV1() async {
let mockTransport = MockRendezvousTransport()

let aliceService = RendezvousService(transport: mockTransport)
let aliceService = RendezvousService(transport: mockTransport, algorithm: .ECDH_V1)

guard case .success(let rendezvousDetails) = await aliceService.createRendezvous(),
let alicePublicKey = rendezvousDetails.key else {
Expand All @@ -32,7 +32,49 @@ class RendezvousServiceTests: XCTestCase {

XCTAssertNotNil(mockTransport.rendezvousURL)

let bobService = RendezvousService(transport: mockTransport)
let bobService = RendezvousService(transport: mockTransport, algorithm: .ECDH_V1)

guard case .success = await bobService.joinRendezvous(withPublicKey: alicePublicKey) 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)
}

func testEnd2EndV2() async {
let mockTransport = MockRendezvousTransport()

let aliceService = RendezvousService(transport: mockTransport, algorithm: .ECDH_V2)

guard case .success(let rendezvousDetails) = await aliceService.createRendezvous(),
let alicePublicKey = rendezvousDetails.key else {
XCTFail("Rendezvous creation failed")
return
}

XCTAssertNotNil(mockTransport.rendezvousURL)

let bobService = RendezvousService(transport: mockTransport, algorithm: .ECDH_V2)

guard case .success = await bobService.joinRendezvous(withPublicKey: alicePublicKey) else {
XCTFail("Bob failed to join")
Expand Down
1 change: 1 addition & 0 deletions changelog.d/pr-7372.change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Updates to protocol used for Sign in with QR code.