diff --git a/Riot/Modules/Rendezvous/RendezvousModels.swift b/Riot/Modules/Rendezvous/RendezvousModels.swift index af4dbc50f..24edbf1cf 100644 --- a/Riot/Modules/Rendezvous/RendezvousModels.swift +++ b/Riot/Modules/Rendezvous/RendezvousModels.swift @@ -33,5 +33,6 @@ struct RendezvousTransportDetails: Codable { } struct RendezvousMessage: Codable { - var combined: String + var iv: String + var ciphertext: String } diff --git a/Riot/Modules/Rendezvous/RendezvousService.swift b/Riot/Modules/Rendezvous/RendezvousService.swift index 370703ea1..84583a583 100644 --- a/Riot/Modules/Rendezvous/RendezvousService.swift +++ b/Riot/Modules/Rendezvous/RendezvousService.swift @@ -26,14 +26,12 @@ enum RendezvousServiceError: Error { case transportError(RendezvousTransportError) } -enum RendezvousServiceCallback { - case error(RendezvousServiceError) -} - +/// Algorithm name as per MSC3903 enum RendezvousChannelAlgorithm: String { - case ECDH_V1 = "m.rendezvous.v1.x25519-aes-sha256" + case ECDH_V1 = "m.rendezvous.v1.curve25519-aes-sha256" } +/// Allows communication through a secure channel. Based on MSC3886 and MSC3903 @MainActor class RendezvousService { private let transport: RendezvousTransportProtocol @@ -47,6 +45,7 @@ class RendezvousService { 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, @@ -60,6 +59,8 @@ class RendezvousService { } } + /// After creation we need to wait for the pair to publish its public key as well + /// At the end of this a symmetric key will be available for encryption func waitForInterlocutor() async -> Result<(), RendezvousServiceError> { switch await transport.get() { case .failure(let error): @@ -86,6 +87,8 @@ 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() async -> Result<(), RendezvousServiceError> { guard case let .success(data) = await transport.get() else { return .failure(.internalError) @@ -100,6 +103,8 @@ class RendezvousService { return .failure(.invalidInterlocutorKey) } + self.interlocutorPublicKey = interlocutorPublicKey + let publicKeyString = self.privateKey.publicKey.rawRepresentation.base64EncodedString() let payload = RendezvousDetails(algorithm: RendezvousChannelAlgorithm.ECDH_V1.rawValue, key: publicKeyString) @@ -118,17 +123,28 @@ class RendezvousService { return .success(()) } + /// Send arbitrary data over the secure channel + /// This will use the previously generated symmetric key to AES encrypt the payload + /// - Parameter data: the data to be encrypted and sent + /// - Returns: nothing if succeeded or a RendezvousServiceError failure func send(data: Data) async -> Result<(), RendezvousServiceError> { guard let symmetricKey = symmetricKey else { return .failure(.channelNotReady) } - - guard let sealedBox = try? AES.GCM.seal(data, using: symmetricKey), - let combinedData = sealedBox.combined else { + + // Generate a custom random 256 bit nonce/iv as per MSC3903. The default one is 96 bit. + guard let nonce = try? AES.GCM.Nonce(data: generateRandomData(ofLength: 32)), + let sealedBox = try? AES.GCM.seal(data, using: symmetricKey, nonce: nonce) else { return .failure(.internalError) } - let body = RendezvousMessage(combined: combinedData.base64EncodedString()) + // The resulting cipher text needs to contain both the message and the authentication tag + // in order to play nicely with other platforms + var ciphertext = sealedBox.ciphertext + ciphertext.append(contentsOf: sealedBox.tag) + + let body = RendezvousMessage(iv: Data(nonce).base64EncodedString(), + ciphertext: ciphertext.base64EncodedString()) switch await transport.send(body: body) { case .failure(let transportError): @@ -138,6 +154,9 @@ class RendezvousService { } } + + /// Waits for and returns newly available rendezvous channel data + /// - Returns: The unencrypted data or a RendezvousServiceError func receive() async -> Result { guard let symmetricKey = symmetricKey else { return .failure(.channelNotReady) @@ -151,8 +170,17 @@ class RendezvousService { return .failure(.decodingError) } - guard let combinedData = Data(base64Encoded: response.combined), - let sealedBox = try? AES.GCM.SealedBox(combined: combinedData), + guard let ciphertextData = Data(base64Encoded: response.ciphertext), + let nonceData = Data(base64Encoded: response.iv), + let nonce = try? AES.GCM.Nonce(data: nonceData) else { + return .failure(.decodingError) + } + + // Split the ciphertext into the message and authentication tag data + let messageData = ciphertextData.dropLast(16) // The last 16 bytes are the tag + let tagData = ciphertextData.dropFirst(messageData.count) + + guard let sealedBox = try? AES.GCM.SealedBox(nonce: nonce, ciphertext: messageData, tag: tagData), let messageData = try? AES.GCM.open(sealedBox, using: symmetricKey) else { return .failure(.decodingError) } @@ -164,7 +192,21 @@ class RendezvousService { // MARK: - Private private func generateSymmetricKeyFrom(sharedSecret: SharedSecret) -> SymmetricKey { + // MSC3903 asks for a 8 zero byte salt when deriving the keys let salt = Data(repeating: 0, count: 8) return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(), outputByteCount: 32) } + + private func generateRandomData(ofLength length: Int) -> Data { + var data = Data(count: length) + _ = data.withUnsafeMutableBytes { pointer -> Int32 in + if let baseAddress = pointer.baseAddress { + return SecRandomCopyBytes(kSecRandomDefault, length, baseAddress) + } + + return 0 + } + + return data + } } diff --git a/Riot/Modules/Rendezvous/RendezvousTransport.swift b/Riot/Modules/Rendezvous/RendezvousTransport.swift index 6ea923d63..40b7db2cb 100644 --- a/Riot/Modules/Rendezvous/RendezvousTransport.swift +++ b/Riot/Modules/Rendezvous/RendezvousTransport.swift @@ -63,15 +63,12 @@ class RendezvousTransport: RendezvousTransportProtocol { } else if httpURLResponse.statusCode == 304 { continuation.resume(returning: .success(nil)) } else if httpURLResponse.statusCode == 200 { - if httpURLResponse.allHeaderFields["Content-Type"] as? String != "application/json" { - continuation.resume(returning: .success(nil)) - } else { - if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { - self.currentEtag = etag - } - - continuation.resume(returning: .success(data)) + // The resouce changed, update the etag + if let etag = httpURLResponse.allHeaderFields["Etag"] as? String { + self.currentEtag = etag } + + continuation.resume(returning: .success(data)) } }.resume() } diff --git a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift index 6aea032b4..4c608ace8 100644 --- a/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift +++ b/Riot/Modules/Rendezvous/RendezvousTransportProtocol.swift @@ -23,11 +23,21 @@ enum RendezvousTransportError: Error { case rendezvousCancelled } +/// HTTP based MSC3886 channel implementation @MainActor protocol RendezvousTransportProtocol { + /// The current rendezvous endpoint. + /// Automatically assigned after a successful creation var rendezvousURL: URL? { get } + /// Creates a new rendezvous point containing the body + /// - Parameter body: arbitrary data to publish on the rendevous + /// - Returns:a transport error in case of failure func create(body: T) async -> Result<(), RendezvousTransportError> + + /// Waits for and returns newly availalbe rendezvous data func get() async -> Result + + /// Publishes new rendezvous data func send(body: T) async -> Result<(), RendezvousTransportError> }