diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index c5888779b..c48c97224 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -826,6 +826,7 @@ FDC4387427B5BB9B00C60D73 /* Promise+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */; }; FDC4387627B5BEF300C60D73 /* FileDownloadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */; }; FDC4387827B5C35400C60D73 /* SendMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */; }; + FDC4387C27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1917,6 +1918,7 @@ FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Utilities.swift"; sourceTree = ""; }; FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = ""; }; FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = ""; }; + FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyOnionRequestAPI.swift; sourceTree = ""; }; FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3363,6 +3365,7 @@ C3C2A5B9255385ED00C340D1 /* Configuration.swift */, C3C2A5BD255385EE00C340D1 /* Notification+OnionRequestAPI.swift */, C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, + FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, C3C2A5B7255385EC00C340D1 /* Snode.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, @@ -4795,6 +4798,7 @@ buildActionMask = 2147483647; files = ( C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, + FDC4387C27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift in Sources */, C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */, C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, diff --git a/SessionMessagingKit/File Server/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift index 6e885823f..7829d9b74 100644 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ b/SessionMessagingKit/File Server/FileServerAPIV2.swift @@ -94,7 +94,9 @@ public final class FileServerAPIV2 : NSObject { preconditionFailure("It's currently not allowed to send non onion routed requests.") } - return OnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: serverPublicKey) + // TODO: Upgrade this to use the V4 onion requests once supported + return LegacyOnionRequestAPI.sendOnionRequest(urlRequest, to: server, using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } } // MARK: File Storage diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 164f422a9..c3d5d4c32 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -101,7 +101,8 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject guard let fileAsString = pointer.downloadURL.split(separator: "/").last, let file = UInt64(fileAsString) else { return handleFailure(Error.invalidURL) } - OpenGroupAPIV2.download(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in + // TODO: Upgrade this to use the non-legacy version + OpenGroupAPIV2.legacyDownload(file, from: v2OpenGroup.room, on: v2OpenGroup.server).done(on: DispatchQueue.global(qos: .userInitiated)) { data in self.handleDownloadedAttachment(data: data, temporaryFilePath: temporaryFilePath, pointer: pointer, failureHandler: handleFailure) }.catch(on: DispatchQueue.global()) { error in handleFailure(error) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 401824b69..e48255979 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -69,7 +69,16 @@ public final class AttachmentUploadJob : NSObject, Job, NSCoding { // NSObject/N guard !stream.isUploaded else { return handleSuccess() } // Should never occur let storage = SNMessagingKitConfiguration.shared.storage if let v2OpenGroup = storage.getV2OpenGroup(for: threadID) { - AttachmentUploadJob.upload(stream, using: { data in return OpenGroupAPIV2.upload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) }, encrypt: false, onSuccess: handleSuccess, onFailure: handleFailure) + AttachmentUploadJob.upload( + stream, + using: { data in + // TODO: Upgrade this to use the non-legacy version + return OpenGroupAPIV2.legacyUpload(data, to: v2OpenGroup.room, on: v2OpenGroup.server) + }, + encrypt: false, + onSuccess: handleSuccess, + onFailure: handleFailure + ) } else { AttachmentUploadJob.upload(stream, using: FileServerAPIV2.upload, encrypt: true, onSuccess: handleSuccess, onFailure: handleFailure) } diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift index ddbeeb7ec..05101987c 100644 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -3,6 +3,7 @@ import Foundation import PromiseKit import SessionUtilitiesKit +import SessionSnodeKit extension OpenGroupAPIV2 { // MARK: - BatchSubRequest @@ -66,10 +67,11 @@ public extension Decodable { } } -extension Promise where T == Data { +extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { func decoded(as types: OpenGroupAPIV2.BatchResponseTypes, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { - self.map(on: queue) { data -> OpenGroupAPIV2.BatchResponse in + self.map(on: queue) { responseInfo, maybeData -> OpenGroupAPIV2.BatchResponse in // Need to split the data into an array of data so each item can be Decoded correctly + guard let data: Data = maybeData else { throw OpenGroupAPIV2.Error.parsingFailed } guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { throw OpenGroupAPIV2.Error.parsingFailed } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift index 6eb84e145..8c7b7420e 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift @@ -4,7 +4,8 @@ extension OpenGroupAPIV2 { @objc(deleteMessageWithServerID:fromRoom:onServer:) public static func objc_deleteMessage(with serverID: Int64, from room: String, on server: String) -> AnyPromise { - return AnyPromise.from(deleteMessage(with: serverID, from: room, on: server)) + // TODO: Upgrade this to use the non-legacy version + return AnyPromise.from(legacyDeleteMessage(with: serverID, from: room, on: server)) } @objc(isUserModerator:forRoom:onServer:) diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift index 6741050ca..a4cf3231b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift @@ -156,7 +156,9 @@ public final class OpenGroupAPIV2: NSObject { ) return send(request) - .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in + guard let data: Data = maybeData else { throw Error.parsingFailed } + let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) return when( @@ -241,7 +243,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Capabilities - public static func capabilities(on server: String) -> Promise { + public static func capabilities(on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Capabilities)> { let request: Request = Request( server: server, endpoint: .capabilities, @@ -255,7 +257,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Room - public static func rooms(for server: String) -> Promise<[Room]> { + public static func rooms(for server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Room])> { let request: Request = Request( server: server, endpoint: .rooms @@ -265,7 +267,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: [Room].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func room(for roomToken: String, on server: String) -> Promise { + public static func room(for roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Room)> { let request: Request = Request( server: server, endpoint: .room(roomToken) @@ -275,7 +277,7 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: Room.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise { + public static func roomPollInfo(lastUpdated: Int64, for roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, RoomPollInfo)> { let request: Request = Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) @@ -294,7 +296,7 @@ public final class OpenGroupAPIV2: NSObject { whisperTo: String?, whisperMods: Bool, with serverPublicKey: String - ) -> Promise { + ) -> Promise<(OnionRequestAPI.ResponseInfo, Message)> { // TODO: Change this to use '.blinded' once it's working guard let signedRequest: (data: Data, signature: Data) = SendMessageRequest.sign(message: plaintext, for: .standard, with: serverPublicKey) else { return Promise(error: Error.signingFailed) @@ -322,9 +324,8 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: Message.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - - - public static func recentMessages(in roomToken: String, on server: String) -> Promise<[Message]> { + + public static func recentMessages(in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -337,12 +338,13 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in process(messages: messages, for: roomToken, on: server) + .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<[Message]> { + public static func messagesBefore(messageId: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -355,12 +357,13 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in process(messages: messages, for: roomToken, on: server) + .map { processedMessages in (responseInfo, processedMessages) } } } - public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<[Message]> { + public static func messagesSince(seqNo: Int64, in roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> { // TODO: Recent vs. Since? let request: Request = Request( server: server, @@ -373,11 +376,47 @@ public final class OpenGroupAPIV2: NSObject { return send(request) .decoded(as: [Message].self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) - .then(on: OpenGroupAPIV2.workQueue) { messages -> Promise<[Message]> in + .then(on: OpenGroupAPIV2.workQueue) { responseInfo, messages -> Promise<(OnionRequestAPI.ResponseInfo, [Message])> in process(messages: messages, for: roomToken, on: server) + .map { processedMessages in (responseInfo, processedMessages) } } } + // MARK: - Pinning + + public static func pinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomPinMessage(roomToken, id: id) + ) + + return send(request) + .map { responseInfo, _ in responseInfo } + } + + public static func unpinMessage(id: Int64, in roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomUnpinMessage(roomToken, id: id) + ) + + return send(request) + .map { responseInfo, _ in responseInfo } + } + + public static func unpinAll(in roomToken: String, on server: String) -> Promise { + let request: Request = Request( + method: .post, + server: server, + endpoint: .roomUnpinAll(roomToken) + ) + + return send(request) + .map { responseInfo, _ in responseInfo } + } + // MARK: - Files // TODO: Shift this logic to the `OpenGroupManager` (makes more sense since it's not API logic) @@ -405,6 +444,7 @@ public final class OpenGroupAPIV2: NSObject { } let promise: Promise = downloadFile(fileId, from: roomToken, on: server) + .map { _, data in data } _ = promise.done(on: OpenGroupAPIV2.workQueue) { imageData in if server == defaultServer { Storage.shared.write { transaction in @@ -418,7 +458,7 @@ public final class OpenGroupAPIV2: NSObject { return promise } - public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise { + public static func uploadFile(_ bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, @@ -433,7 +473,7 @@ public final class OpenGroupAPIV2: NSObject { /// Warning: This approach is less efficient as it expects the data to be base64Encoded (with is 33% larger than binary), please use the binary approach /// whenever possible - public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise { + public static func uploadFile(_ base64EncodedString: String, fileName: String? = nil, to roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileUploadResponse)> { let request: Request = Request( method: .post, server: server, @@ -446,21 +486,26 @@ public final class OpenGroupAPIV2: NSObject { .decoded(as: FileUploadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } - public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise { + public static func downloadFile(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data)> { let request: Request = Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ) return send(request) + .map { responseInfo, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } + + return (responseInfo, data) + } } - public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise { + public static func downloadFileJson(_ fileId: Int64, from roomToken: String, on server: String) -> Promise<(OnionRequestAPI.ResponseInfo, FileDownloadResponse)> { let request: Request = Request( server: server, endpoint: .roomFileIndividualJson(roomToken, fileId) ) - + // TODO: This endpoint is getting rewritten to return just data (properties would come through as headers) return send(request) .decoded(as: FileDownloadResponse.self, on: OpenGroupAPIV2.workQueue, error: Error.parsingFailed) } @@ -532,6 +577,7 @@ public final class OpenGroupAPIV2: NSObject { completion: { let promise = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { OpenGroupAPIV2.rooms(for: defaultServer) + .map { _, data in data } } _ = promise.done(on: OpenGroupAPIV2.workQueue) { items in items @@ -555,7 +601,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Convenience - private static func send(_ request: Request) -> Promise { + private static func send(_ request: Request, through api: OnionRequestAPIType.Type = OnionRequestAPI.self) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } var urlRequest: URLRequest = URLRequest(url: url) @@ -571,39 +617,6 @@ public final class OpenGroupAPIV2: NSObject { } if request.isAuthRequired { - // Determine if we should be using legacy auth for this endpoint - // TODO: Might need to store this at an OpenGroup level (so all requests can use the appropriate method). - if request.endpoint.useLegacyAuth { - // Because legacy auth happens on a per-room basis, we need to have a room to - // make an authenticated request - guard let room = request.room else { - return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - } - - return legacyGetAuthToken(for: room, on: request.server) - .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise in - urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) - - let promise = OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, using: publicKey) - promise.catch(on: OpenGroupAPIV2.workQueue) { error in - // A 401 means that we didn't provide a (valid) auth token for a route - // that required one. We use this as an indication that the token we're - // using has expired. Note that a 403 has a different meaning; it means - // that we provided a valid token but it doesn't have a high enough - // permission level for the route in question. - if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { - let storage = SNMessagingKitConfiguration.shared.storage - - storage.writeSync { transaction in - storage.removeAuthToken(for: room, on: request.server, using: transaction) - } - } - } - - return promise - } - } - // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(urlRequest, with: publicKey) else { return Promise(error: Error.signingFailed) @@ -679,7 +692,8 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response = try data.decoded(as: AuthTokenResponse.self, customError: Error.parsingFailed) let symmetricKey = try AESGCM.generateSymmetricKey(x25519PublicKey: response.challenge.ephemeralPublicKey, x25519PrivateKey: userKeyPair.privateKey) @@ -711,7 +725,7 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in authToken } } /// Should be called when leaving a group. @@ -723,7 +737,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyAuthToken(legacyAuth: true) ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in let storage = SNMessagingKitConfiguration.shared.storage storage.write { transaction in @@ -796,8 +810,9 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request) - .then(on: OpenGroupAPIV2.workQueue) { data -> Promise in + return legacySend(request) + .then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyCompactPollResponse = try data.decoded(as: LegacyCompactPollResponse.self, customError: Error.parsingFailed) return when( @@ -853,8 +868,9 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyRoomsResponse = try data.decoded(as: LegacyRoomsResponse.self, customError: Error.parsingFailed) return response.rooms @@ -869,8 +885,9 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyGetInfoResponse = try data.decoded(as: LegacyGetInfoResponse.self, customError: Error.parsingFailed) return response.room @@ -907,7 +924,8 @@ public final class OpenGroupAPIV2: NSObject { isAuthRequired: false ) - let promise: Promise = send(request).map(on: OpenGroupAPIV2.workQueue) { data in + let promise: Promise = legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) if server == defaultServer { @@ -931,8 +949,9 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyMemberCount(legacyAuth: true) ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: MemberCountResponse = try data.decoded(as: MemberCountResponse.self, customError: Error.parsingFailed) let storage = SNMessagingKitConfiguration.shared.storage @@ -946,7 +965,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy File Storage - public static func upload(_ file: Data, to room: String, on server: String) -> Promise { + public static func legacyUpload(_ file: Data, to room: String, on server: String) -> Promise { let requestBody: FileUploadBody = FileUploadBody(file: file.base64EncodedString()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -955,17 +974,19 @@ public final class OpenGroupAPIV2: NSObject { let request = Request(method: .post, server: server, room: room, endpoint: .legacyFiles, body: body) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileUploadResponse = try data.decoded(as: LegacyFileUploadResponse.self, customError: Error.parsingFailed) return response.fileId } } - public static func download(_ file: UInt64, from room: String, on server: String) -> Promise { + public static func legacyDownload(_ file: UInt64, from room: String, on server: String) -> Promise { let request = Request(server: server, room: room, endpoint: .legacyFile(file)) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: LegacyFileDownloadResponse = try data.decoded(as: LegacyFileDownloadResponse.self, customError: Error.parsingFailed) return response.data @@ -981,7 +1002,8 @@ public final class OpenGroupAPIV2: NSObject { } let request = Request(method: .post, server: server, room: room, endpoint: .legacyMessages, body: body) - return send(request).map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let message: OpenGroupMessageV2 = try data.decoded(as: OpenGroupMessageV2.self, customError: Error.parsingFailed) Storage.shared.write { transaction in Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) @@ -1001,7 +1023,8 @@ public final class OpenGroupAPIV2: NSObject { ].compactMapValues { $0 } ) - return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[OpenGroupMessageV2]> in + return legacySend(request).then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<[OpenGroupMessageV2]> in + guard let data: Data = maybeData else { throw Error.parsingFailed } let messages: [OpenGroupMessageV2] = try data.decoded(as: [OpenGroupMessageV2].self, customError: Error.parsingFailed) return legacyProcess(messages: messages, for: room, on: server) @@ -1010,7 +1033,7 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Message Deletion - public static func deleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { + public static func legacyDeleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, server: server, @@ -1018,10 +1041,10 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyMessagesForServer(serverID) ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func getDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { + public static func legacyGetDeletedMessages(for room: String, on server: String) -> Promise<[Deletion]> { let storage = SNMessagingKitConfiguration.shared.storage let request: Request = Request( @@ -1033,7 +1056,8 @@ public final class OpenGroupAPIV2: NSObject { ].compactMapValues { $0 } ) - return send(request).then(on: OpenGroupAPIV2.workQueue) { data -> Promise<[Deletion]> in + return legacySend(request).then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<[Deletion]> in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: DeletedMessagesResponse = try data.decoded(as: DeletedMessagesResponse.self, customError: Error.parsingFailed) return process(deletions: response.deletions, for: room, on: server) @@ -1042,15 +1066,16 @@ public final class OpenGroupAPIV2: NSObject { // MARK: - Legacy Moderation - public static func getModerators(for room: String, on server: String) -> Promise<[String]> { + public static func legacyGetModerators(for room: String, on server: String) -> Promise<[String]> { let request: Request = Request( server: server, room: room, endpoint: .legacyModerators ) - return send(request) - .map(on: OpenGroupAPIV2.workQueue) { data in + return legacySend(request) + .map(on: OpenGroupAPIV2.workQueue) { _, maybeData in + guard let data: Data = maybeData else { throw Error.parsingFailed } let response: ModeratorsResponse = try data.decoded(as: ModeratorsResponse.self, customError: Error.parsingFailed) if var x = self.moderators[server] { @@ -1065,7 +1090,7 @@ public final class OpenGroupAPIV2: NSObject { } } - public static func ban(_ publicKey: String, from room: String, on server: String) -> Promise { + public static func legacyBan(_ publicKey: String, from room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -1080,10 +1105,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func banAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { + public static func legacyBanAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise { let requestBody: PublicKeyBody = PublicKeyBody(publicKey: getUserHexEncodedPublicKey()) guard let body: Data = try? JSONEncoder().encode(requestBody) else { @@ -1098,10 +1123,10 @@ public final class OpenGroupAPIV2: NSObject { body: body ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } - public static func unban(_ publicKey: String, from room: String, on server: String) -> Promise { + public static func legacyUnban(_ publicKey: String, from room: String, on server: String) -> Promise { let request: Request = Request( method: .delete, server: server, @@ -1109,7 +1134,7 @@ public final class OpenGroupAPIV2: NSObject { endpoint: .legacyBlockListIndividual(publicKey) ) - return send(request).map(on: OpenGroupAPIV2.workQueue) { _ in } + return legacySend(request).map(on: OpenGroupAPIV2.workQueue) { _ in } } // MARK: - Processing @@ -1164,4 +1189,58 @@ public final class OpenGroupAPIV2: NSObject { return Promise.value(deletions) } + + // MARK: - Legacy Convenience + + private static func legacySend(_ request: Request, through api: OnionRequestAPIType.Type = LegacyOnionRequestAPI.self) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + guard let url: URL = request.url else { return Promise(error: Error.invalidURL) } + + var urlRequest: URLRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.allHTTPHeaderFields = request.headers + .setting(.room, request.room) // TODO: Is this needed anymore? Add at the request level?. + .toHTTPHeaders() + urlRequest.httpBody = request.body + + if request.useOnionRouting { + guard let publicKey = SNMessagingKitConfiguration.shared.storage.getOpenGroupPublicKey(for: request.server) else { + return Promise(error: Error.noPublicKey) + } + + if request.isAuthRequired { + // Because legacy auth happens on a per-room basis, we need to have a room to + // make an authenticated request + guard let room = request.room else { + return api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + } + + return legacyGetAuthToken(for: room, on: request.server) + .then(on: OpenGroupAPIV2.workQueue) { authToken -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> in + urlRequest.setValue(authToken, forHTTPHeaderField: Header.authorization.rawValue) + + let promise = api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + promise.catch(on: OpenGroupAPIV2.workQueue) { error in + // A 401 means that we didn't provide a (valid) auth token for a route + // that required one. We use this as an indication that the token we're + // using has expired. Note that a 403 has a different meaning; it means + // that we provided a valid token but it doesn't have a high enough + // permission level for the route in question. + if case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) = error, statusCode == 401 { + let storage = SNMessagingKitConfiguration.shared.storage + + storage.writeSync { transaction in + storage.removeAuthToken(for: room, on: request.server, using: transaction) + } + } + } + + return promise + } + } + + return api.sendOnionRequest(urlRequest, to: request.server, target: "/loki/v3/lsrpc", using: publicKey) + } + + preconditionFailure("It's currently not allowed to send non onion routed requests.") + } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift index 6ce29d141..c29191ae1 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift @@ -79,7 +79,7 @@ public final class OpenGroupManagerV2 : NSObject { // } OpenGroupAPIV2.room(for: room, on: server) - .done(on: DispatchQueue.global(qos: .userInitiated)) { room in + .done(on: DispatchQueue.global(qos: .userInitiated)) { _, room in // Create the open group model and the thread let openGroup: OpenGroupV2 = OpenGroupV2( server: server, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 7289420aa..bd2d2fcc3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -216,6 +216,7 @@ public final class MessageSender : NSObject { handleFailure(with: error, using: transaction) return promise } + // Send the result let base64EncodedData = wrappedMessage.base64EncodedString() let timestamp = UInt64(Int64(message.sentTimestamp!) + SnodeAPI.clockOffset) @@ -280,6 +281,7 @@ public final class MessageSender : NSObject { let (promise, seal) = Promise.pending() let storage = SNMessagingKitConfiguration.shared.storage let transaction = transaction as! YapDatabaseReadWriteTransaction + // Set the timestamp, sender and recipient if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set message.sentTimestamp = NSDate.millisecondTimestamp() @@ -371,37 +373,30 @@ public final class MessageSender : NSObject { preconditionFailure() } -// let openGroupMessage = OpenGroupMessageV2(serverID: nil, sender: nil, sentTimestamp: message.sentTimestamp!, -// base64EncodedData: plaintext.base64EncodedString(), base64EncodedSignature: nil) + OpenGroupAPIV2 + .send( + plaintext, + to: room, + on: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + with: openGroupV2.publicKey + ) + .done(on: DispatchQueue.global(qos: .userInitiated)) { responseInfo, data in + message.openGroupServerMessageID = given(data.seqNo) { UInt64($0) } - //OpenGroupAPIV2.send(openGroupMessage, to: room, on: server, with: openGroupV2.publicKey) - return promise // TODO: Remove!!! -// OpenGroupAPIV2 -// .send( -// plaintext, -// to: room, -// on: server, -// whisperTo: whisperTo, -// whisperMods: whisperMods, -// with: openGroupV2.publicKey -// ) -// .done(on: DispatchQueue.global(qos: .userInitiated)) { response in -// print("RAWR") -//// message.openGroupServerMessageID = given(response.serverID) { UInt64($0) } -//// storage.write(with: { transaction in -//// Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp, using: transaction) -//// -//// MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: response.sentTimestamp, using: transaction) -//// seal.fulfill(()) -//// }, completion: { }) -// } -// .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in -// storage.write(with: { transaction in -// handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) -// }, completion: { }) -// } -// // Return -// return promise + Storage.shared.write { transaction in + MessageSender.handleSuccessfulMessageSend(message, to: destination, serverTimestamp: UInt64(floor(data.posted)), using: transaction) + seal.fulfill(()) + } + } + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + storage.write(with: { transaction in + handleFailure(with: error, using: transaction as! YapDatabaseReadWriteTransaction) + }, completion: { }) + } + + return promise } // MARK: Success & Failure Handling diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index a9f5b8d02..d434bbd56 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -51,14 +51,17 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { - return SNLog("Couldn't unregister from push notifications.") + // TODO: Update this to use the V4 union requests once supported + LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { response in + guard let response: UnregisterResponse = try? response.decoded(as: UnregisterResponse.self) else { + return SNLog("Couldn't unregister from push notifications.") + } + guard response.code != 0 else { + return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") + } } - guard response.code != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") - } - } } promise.catch2 { error in SNLog("Couldn't unregister from push notifications.") @@ -99,18 +102,21 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { - return SNLog("Couldn't register device token.") + // TODO: Update this to use the V4 union requests once supported + LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { response in + guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { + return SNLog("Couldn't register device token.") + } + guard response.code != 0 else { + return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") + } + + userDefaults[.deviceToken] = hexEncodedToken + userDefaults[.lastDeviceTokenUpload] = now + userDefaults[.isUsingFullAPNs] = true } - guard response.code != 0 else { - return SNLog("Couldn't register device token due to error: \(response.message ?? "nil").") - } - - userDefaults[.deviceToken] = hexEncodedToken - userDefaults[.lastDeviceTokenUpload] = now - userDefaults[.isUsingFullAPNs] = true - } } promise.catch2 { error in SNLog("Couldn't register device token.") @@ -144,14 +150,17 @@ public final class PushNotificationAPI : NSObject { request.httpBody = body let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey).map2 { response in - guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + // TODO: Update this to use the V4 union requests once supported + LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: serverPublicKey) + .map2 { json in try JSONSerialization.data(withJSONObject: json, options: []) } + .map2 { response in + guard let response: RegisterResponse = try? response.decoded(as: RegisterResponse.self) else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + } + guard response.code != 0 else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") + } } - guard response.code != 0 else { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") - } - } } promise.catch2 { error in SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift index 549a8dda0..b5a53d4fe 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift @@ -47,25 +47,22 @@ public final class OpenGroupPollerV2 : NSObject { let (promise, seal) = Promise.pending() promise.retainUntilComplete() - // TODO: Update to use the non-legacy version -// OpenGroupAPIV2.compactPoll(server) - OpenGroupAPIV2.legacyCompactPoll(server) - .done(on: OpenGroupAPIV2.workQueue) { [weak self] response in - guard let self = self else { return } - self.isPolling = false - response.results.forEach { self.handleCompactPollBody($0, isBackgroundPoll: isBackgroundPoll) } + OpenGroupAPIV2.poll(server) + .done(on: OpenGroupAPIV2.workQueue) { [weak self] _ in + self?.isPolling = false + // TODO: Handle response seal.fulfill(()) } - .catch(on: OpenGroupAPIV2.workQueue) { error in + .catch(on: OpenGroupAPIV2.workQueue) { [weak self] error in SNLog("Open group polling failed due to error: \(error).") - self.isPolling = false + self?.isPolling = false seal.fulfill(()) // The promise is just used to keep track of when we're done } return promise } - private func handleCompactPollBody(_ body: OpenGroupAPIV2.CompactPollResponse.Result, isBackgroundPoll: Bool) { + private func handleCompactPollBody(_ body: OpenGroupAPIV2.LegacyCompactPollResponse.Result, isBackgroundPoll: Bool) { let storage = SNMessagingKitConfiguration.shared.storage // - Messages // Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't find those older messages diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift index c75808c7a..e39b21082 100644 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -2,6 +2,7 @@ import Foundation import PromiseKit +import SessionSnodeKit extension Promise where T == Data { func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise { @@ -10,3 +11,15 @@ extension Promise where T == Data { } } } + +extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) { + func decoded(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<(OnionRequestAPI.ResponseInfo, R)> { + self.map(on: queue) { responseInfo, maybeData -> (OnionRequestAPI.ResponseInfo, R) in + guard let data: Data = maybeData else { + throw OpenGroupAPIV2.Error.parsingFailed + } + + return (responseInfo, try data.decoded(as: type, customError: error)) + } + } +} diff --git a/SessionSnodeKit/LegacyOnionRequestAPI.swift b/SessionSnodeKit/LegacyOnionRequestAPI.swift new file mode 100644 index 000000000..9f897e575 --- /dev/null +++ b/SessionSnodeKit/LegacyOnionRequestAPI.swift @@ -0,0 +1,455 @@ +import CryptoSwift +import PromiseKit +import SessionUtilitiesKit + +/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. +public enum LegacyOnionRequestAPI: OnionRequestAPIType { + private static var buildPathsPromise: Promise<[Path]>? = nil + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + private static var pathFailureCount: [Path:UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + private static var snodeFailureCount: [Snode:UInt] = [:] + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + public static var guardSnodes: Set = [] + public static var paths: [Path] = [] // Not a set to ensure we consistently show the same path to the user + // MARK: Settings + public static let maxRequestSize = 10_000_000 // 10 MB + /// The number of snodes (including the guard snode) in a path. + private static let pathSize: UInt = 3 + /// The number of times a path can fail before it's replaced. + private static let pathFailureThreshold: UInt = 3 + /// The number of times a snode can fail before it's replaced. + private static let snodeFailureThreshold: UInt = 3 + /// The number of paths to maintain. + public static let targetPathCount: UInt = 2 + + /// The number of guard snodes required to maintain `targetPathCount` paths. + private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path + + // MARK: Error + public enum Error : LocalizedError { + case httpRequestFailedAtDestination(statusCode: UInt, json: JSON, destination: OnionRequestAPI.Destination) + case insufficientSnodes + case invalidURL + case missingSnodeVersion + case snodePublicKeySetMissing + case unsupportedSnodeVersion(String) + + public var errorDescription: String? { + switch self { + case .httpRequestFailedAtDestination(let statusCode, _, let destination): + if statusCode == 429 { + return "Rate limited." + } else { + return "HTTP request failed at destination (\(destination)) with status code: \(statusCode)." + } + case .insufficientSnodes: return "Couldn't find enough Service Nodes to build a path." + case .invalidURL: return "Invalid URL" + case .missingSnodeVersion: return "Missing Service Node version." + case .snodePublicKeySetMissing: return "Missing Service Node public key set." + case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + } + } + } + + // MARK: Path + public typealias Path = [Snode] + + // MARK: Onion Building Result + private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) + + // MARK: Private API + /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. + private static func testSnode(_ snode: Snode) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + let url = "\(snode.address):\(snode.port)/get_stats/v1" + let timeout: TimeInterval = 3 // Use a shorter timeout for testing + HTTP.execute(.get, url, timeout: timeout).done2 { json in + guard let version = json["version"] as? String else { return seal.reject(Error.missingSnodeVersion) } + if version >= "2.0.7" { + seal.fulfill(()) + } else { + SNLog("Unsupported snode version: \(version).") + seal.reject(Error.unsupportedSnodeVersion(version)) + } + }.catch2 { error in + seal.reject(error) + } + } + return promise + } + + /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes` + /// if not enough (reliable) snodes are available. + private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { + if guardSnodes.count >= targetGuardSnodeCount { + return Promise> { $0.fulfill(guardSnodes) } + } else { + SNLog("Populating guard snode cache.") + var unusedSnodes = SnodeAPI.snodePool.subtracting(reusableGuardSnodes) // Sync on LokiAPI.workQueue + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { return Promise(error: Error.insufficientSnodes) } + func getGuardSnode() -> Promise { + // randomElement() uses the system's default random generator, which is cryptographically secure + guard let candidate = unusedSnodes.randomElement() else { return Promise { $0.reject(Error.insufficientSnodes) } } + unusedSnodes.remove(candidate) // All used snodes should be unique + SNLog("Testing guard snode: \(candidate).") + // Loop until a reliable guard snode is found + return testSnode(candidate).map2 { candidate }.recover(on: DispatchQueue.main) { _ in + withDelay(0.1, completionQueue: Threading.workQueue) { getGuardSnode() } + } + } + let promises = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)).map { _ in getGuardSnode() } + return when(fulfilled: promises).map2 { guardSnodes in + let guardSnodesAsSet = Set(guardSnodes + reusableGuardSnodes) + OnionRequestAPI.guardSnodes = guardSnodesAsSet + return guardSnodesAsSet + } + } + } + + /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` + /// if not enough (reliable) snodes are available. + @discardableResult + private static func buildPaths(reusing reusablePaths: [Path]) -> Promise<[Path]> { + if let existingBuildPathsPromise = buildPathsPromise { return existingBuildPathsPromise } + SNLog("Building onion request paths.") + DispatchQueue.main.async { + NotificationCenter.default.post(name: .buildingPaths, object: nil) + } + let reusableGuardSnodes = reusablePaths.map { $0[0] } + let promise: Promise<[Path]> = getGuardSnodes(reusing: reusableGuardSnodes).map2 { guardSnodes -> [Path] in + var unusedSnodes = SnodeAPI.snodePool.subtracting(guardSnodes).subtracting(reusablePaths.flatMap { $0 }) + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) + guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } + // Don't test path snodes as this would reveal the user's IP to them + return guardSnodes.subtracting(reusableGuardSnodes).map { guardSnode in + let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in + // randomElement() uses the system's default random generator, which is cryptographically secure + let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above + unusedSnodes.remove(pathSnode) // All used snodes should be unique + return pathSnode + } + SNLog("Built new onion request path: \(result.prettifiedDescription).") + return result + } + }.map2 { paths in + OnionRequestAPI.paths = paths + reusablePaths + SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + SNLog("Persisting onion request paths to database.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + } + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + return paths + } + promise.done2 { _ in buildPathsPromise = nil } + promise.catch2 { _ in buildPathsPromise = nil } + buildPathsPromise = promise + return promise + } + + /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. + private static func getPath(excluding snode: Snode?) -> Promise { + guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } + var paths = OnionRequestAPI.paths + if paths.isEmpty { + paths = SNSnodeKitConfiguration.shared.storage.getOnionRequestPaths() + OnionRequestAPI.paths = paths + if !paths.isEmpty { + guardSnodes.formUnion([ paths[0][0] ]) + if paths.count >= 2 { + guardSnodes.formUnion([ paths[1][0] ]) + } + } + } + // randomElement() uses the system's default random generator, which is cryptographically secure + if paths.count >= targetPathCount { + if let snode = snode { + return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } + } else { + return Promise { $0.fulfill(paths.randomElement()!) } + } + } else if !paths.isEmpty { + if let snode = snode { + if let path = paths.first(where: { !$0.contains(snode) }) { + buildPaths(reusing: paths) // Re-build paths in the background + return Promise { $0.fulfill(path) } + } else { + return buildPaths(reusing: paths).map2 { paths in + return paths.filter { !$0.contains(snode) }.randomElement()! + } + } + } else { + buildPaths(reusing: paths) // Re-build paths in the background + return Promise { $0.fulfill(paths.randomElement()!) } + } + } else { + return buildPaths(reusing: []).map2 { paths in + if let snode = snode { + return paths.filter { !$0.contains(snode) }.randomElement()! + } else { + return paths.randomElement()! + } + } + } + } + + private static func dropGuardSnode(_ snode: Snode) { + #if DEBUG + dispatchPrecondition(condition: .onQueue(Threading.workQueue)) + #endif + guardSnodes = guardSnodes.filter { $0 != snode } + } + + private static func drop(_ snode: Snode) throws { + #if DEBUG + dispatchPrecondition(condition: .onQueue(Threading.workQueue)) + #endif + // We repair the path here because we can do it sync. In the case where we drop a whole + // path we leave the re-building up to getPath(excluding:) because re-building the path + // in that case is async. + LegacyOnionRequestAPI.snodeFailureCount[snode] = 0 + var oldPaths = paths + guard let pathIndex = oldPaths.firstIndex(where: { $0.contains(snode) }) else { return } + var path = oldPaths[pathIndex] + guard let snodeIndex = path.firstIndex(of: snode) else { return } + path.remove(at: snodeIndex) + let unusedSnodes = SnodeAPI.snodePool.subtracting(oldPaths.flatMap { $0 }) + guard !unusedSnodes.isEmpty else { throw Error.insufficientSnodes } + // randomElement() uses the system's default random generator, which is cryptographically secure + path.append(unusedSnodes.randomElement()!) + // Don't test the new snode as this would reveal the user's IP + oldPaths.remove(at: pathIndex) + let newPaths = oldPaths + [ path ] + paths = newPaths + SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + SNLog("Persisting onion request paths to database.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: newPaths, using: transaction) + } + } + + private static func drop(_ path: Path) { + #if DEBUG + dispatchPrecondition(condition: .onQueue(Threading.workQueue)) + #endif + LegacyOnionRequestAPI.pathFailureCount[path] = 0 + var paths = LegacyOnionRequestAPI.paths + guard let pathIndex = paths.firstIndex(of: path) else { return } + paths.remove(at: pathIndex) + OnionRequestAPI.paths = paths + SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + if !paths.isEmpty { + SNLog("Persisting onion request paths to database.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: paths, using: transaction) + } else { + SNLog("Clearing onion request paths.") + SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: [], using: transaction) + } + } + } + + /// Builds an onion around `payload` and returns the result. + private static func buildOnion(around payload: JSON, targetedAt destination: OnionRequestAPI.Destination) -> Promise { + var guardSnode: Snode! + var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination + var encryptionResult: AESGCM.EncryptionResult! + var snodeToExclude: Snode? + if case .snode(let snode) = destination { snodeToExclude = snode } + return getPath(excluding: snodeToExclude).then2 { path -> Promise in + guardSnode = path.first! + // Encrypt in reverse order, i.e. the destination first + return OnionRequestAPI.encrypt(payload, for: destination).then2 { r -> Promise in + targetSnodeSymmetricKey = r.symmetricKey + // Recursively encrypt the layers of the onion (again in reverse order) + encryptionResult = r + var path = path + var rhs = destination + func addLayer() -> Promise { + if path.isEmpty { + return Promise { $0.fulfill(encryptionResult) } + } else { + let lhs = OnionRequestAPI.Destination.snode(path.removeLast()) + return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise in + encryptionResult = r + rhs = lhs + return addLayer() + } + } + } + return addLayer() + } + }.map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } + } + + // MARK: Public API + /// Sends an onion request to `snode`. Builds new paths as needed. + public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { + let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] + return sendOnionRequest(with: payload, to: OnionRequestAPI.Destination.snode(snode)).recover2 { error -> Promise in + guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } + throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error + } + } + + /// Sends an onion request to `server`. Builds new paths as needed. + public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + var rawHeaders = request.allHTTPHeaderFields ?? [:] + rawHeaders.removeValue(forKey: "User-Agent") + var headers: JSON = rawHeaders.mapValues { value in + switch value.lowercased() { + case "true": return true + case "false": return false + default: return value + } + } + guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } + var endpoint = url.path.removingPrefix("/") + if let query = url.query { endpoint += "?\(query)" } + let scheme = url.scheme + let port = given(url.port) { UInt16($0) } + let bodyAsString: String + + if let body: Data = request.httpBody { + headers["Content-Type"] = "application/json" // Assume data is JSON + bodyAsString = (String(data: body, encoding: .utf8) ?? "null") + } + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { + headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] + bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + } + else { + bodyAsString = "null" + } + + let payload: JSON = [ + "body" : bodyAsString, + "endpoint" : endpoint, + "method" : request.httpMethod!, + "headers" : headers + ] + let destination = OnionRequestAPI.Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) + let promise = sendOnionRequest(with: payload, to: destination) + .map { (json: JSON) -> (OnionRequestAPI.ResponseInfo, Data?) in + guard let data = try? JSONSerialization.data(withJSONObject: json, options: []) else { throw HTTP.Error.invalidJSON } + + return (OnionRequestAPI.ResponseInfo(code: 200, headers: [:]), data) + } + promise.catch2 { error in + SNLog("Couldn't reach server: \(url) due to error: \(error).") + } + return promise + } + + public static func sendOnionRequest(with payload: JSON, to destination: OnionRequestAPI.Destination) -> Promise { + let (promise, seal) = Promise.pending() + var guardSnode: Snode? + Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` + buildOnion(around: payload, targetedAt: destination).done2 { intermediate in + guardSnode = intermediate.guardSnode + let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" + let finalEncryptionResult = intermediate.finalEncryptionResult + let onion = finalEncryptionResult.ciphertext + if case OnionRequestAPI.Destination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { + SNLog("Approaching request size limit: ~\(onion.count) bytes.") + } + let parameters: JSON = [ + "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() + ] + let body: Data + do { + body = try OnionRequestAPI.encode(ciphertext: onion, json: parameters) + } catch { + return seal.reject(error) + } + let destinationSymmetricKey = intermediate.destinationSymmetricKey + HTTP.execute(.post, url, body: body).done2 { json in + guard let base64EncodedIVAndCiphertext = json["result"] as? String, + let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidJSON) } + do { + let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + guard let json = try JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON, + let statusCode = json["status_code"] as? Int ?? json["status"] as? Int else { return seal.reject(HTTP.Error.invalidJSON) } + if statusCode == 406 { // Clock out of sync + SNLog("The user's clock is out of sync with the service node network.") + seal.reject(SnodeAPI.Error.clockOutOfSync) + } else if let bodyAsString = json["body"] as? String { + guard let bodyAsData = bodyAsString.data(using: .utf8), + let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { return seal.reject(HTTP.Error.invalidJSON) } + if let timestamp = body["t"] as? Int64 { + let offset = timestamp - Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset = offset + } + guard 200...299 ~= statusCode else { + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: body, destination: destination)) + } + seal.fulfill(body) + } else { + guard 200...299 ~= statusCode else { + return seal.reject(Error.httpRequestFailedAtDestination(statusCode: UInt(statusCode), json: json, destination: destination)) + } + seal.fulfill(json) + } + } catch { + seal.reject(error) + } + }.catch2 { error in + seal.reject(error) + } + }.catch2 { error in + seal.reject(error) + } + } + promise.catch2 { error in // Must be invoked on Threading.workQueue + guard case HTTP.Error.httpRequestFailed(let statusCode, let json) = error, let guardSnode = guardSnode else { return } + let path = paths.first { $0.contains(guardSnode) } + func handleUnspecificError() { + guard let path = path else { return } + var pathFailureCount = LegacyOnionRequestAPI.pathFailureCount[path] ?? 0 + pathFailureCount += 1 + if pathFailureCount >= pathFailureThreshold { + dropGuardSnode(guardSnode) + path.forEach { snode in + SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + } + drop(path) + } else { + LegacyOnionRequestAPI.pathFailureCount[path] = pathFailureCount + } + } + let prefix = "Next node not found: " + if let message = json?["result"] as? String, message.hasPrefix(prefix) { + let ed25519PublicKey = message[message.index(message.startIndex, offsetBy: prefix.count)..= snodeFailureThreshold { + SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode) // Intentionally don't throw + do { + try drop(snode) + } catch { + handleUnspecificError() + } + } else { + LegacyOnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount + } + } else { + // Do nothing + } + } else if let message = json?["result"] as? String, message == "Loki Server error" { + // Do nothing + } else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { + // FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet + handleUnspecificError() + } else if statusCode == 0 { // Timeout + // Do nothing + } else { + handleUnspecificError() + } + } + return promise + } +} diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift index 27a7ab31c..deec2a30c 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -14,30 +14,63 @@ internal extension OnionRequestAPI { } /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. + static func encrypt(_ payload: String, for destination: Destination) -> Promise { + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .userInitiated).async { + do { + guard let data = payload.data(using: .utf8) else { + throw Error.invalidRequestInfo + } + + let result = try encrypt(data, for: destination) + seal.fulfill(result) + } + catch (let error) { + seal.reject(error) + } + } + + return promise + } + static func encrypt(_ payload: JSON, for destination: Destination) -> Promise { let (promise, seal) = Promise.pending() DispatchQueue.global(qos: .userInitiated).async { do { guard JSONSerialization.isValidJSONObject(payload) else { return seal.reject(HTTP.Error.invalidJSON) } + // Wrapping isn't needed for file server or open group onion requests switch destination { - case .snode(let snode): - let snodeX25519PublicKey = snode.publicKeySet.x25519Key - let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) - let plaintext = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) - let result = try AESGCM.encrypt(plaintext, for: snodeX25519PublicKey) - seal.fulfill(result) - case .server(_, _, let serverX25519PublicKey, _, _): - let plaintext = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) - let result = try AESGCM.encrypt(plaintext, for: serverX25519PublicKey) - seal.fulfill(result) + case .snode: + let payloadAsData = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) + let data = try encode(ciphertext: payloadAsData, json: [ "headers" : "" ]) + let result = try encrypt(data, for: destination) + seal.fulfill(result) + + case .server: + let data = try JSONSerialization.data(withJSONObject: payload, options: [ .fragmentsAllowed ]) + let result = try encrypt(data, for: destination) + seal.fulfill(result) } - } catch (let error) { + } + catch (let error) { seal.reject(error) } } + return promise } + + private static func encrypt(_ payload: Data, for destination: Destination) throws -> AESGCM.EncryptionResult { + switch destination { + case .snode(let snode): + let snodeX25519PublicKey = snode.publicKeySet.x25519Key + return try AESGCM.encrypt(payload, for: snodeX25519PublicKey) + + case .server(_, _, let serverX25519PublicKey, _, _): + return try AESGCM.encrypt(payload, for: serverX25519PublicKey) + } + } /// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. static func encryptHop(from lhs: Destination, to rhs: Destination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift index 57192182a..84765f969 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -3,8 +3,18 @@ import CryptoSwift import PromiseKit import SessionUtilitiesKit +public protocol OnionRequestAPIType { + static func sendOnionRequest(_ request: URLRequest, to server: String, target: String, using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> +} + +public extension OnionRequestAPIType { + static func sendOnionRequest(_ request: URLRequest, to server: String, using x25519PublicKey: String) -> Promise<(OnionRequestAPI.ResponseInfo, Data?)> { + sendOnionRequest(request, to: server, target: "/oxen/v4/lsrpc", using: x25519PublicKey) + } +} + /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. -public enum OnionRequestAPI { +public enum OnionRequestAPI: OnionRequestAPIType { private static var buildPathsPromise: Promise<[Path]>? = nil /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. private static var pathFailureCount: [Path:UInt] = [:] @@ -49,6 +59,7 @@ public enum OnionRequestAPI { case missingSnodeVersion case snodePublicKeySetMissing case unsupportedSnodeVersion(String) + case invalidRequestInfo public var errorDescription: String? { switch self { @@ -63,9 +74,22 @@ public enum OnionRequestAPI { case .missingSnodeVersion: return "Missing Service Node version." case .snodePublicKeySetMissing: return "Missing Service Node public key set." case .unsupportedSnodeVersion(let version): return "Unsupported Service Node version: \(version)." + case .invalidRequestInfo: return "Invalid Request Info" } } } + + // MARK: RequestInfo + private struct RequestInfo: Codable { + let method: String + let endpoint: String + let headers: [String: String] + } + + public struct ResponseInfo: Codable { + let code: Int + let headers: [String: String] + } // MARK: Path public typealias Path = [Snode] @@ -268,7 +292,7 @@ public enum OnionRequestAPI { } /// Builds an onion around `payload` and returns the result. - private static func buildOnion(around payload: JSON, targetedAt destination: Destination) -> Promise { + private static func buildOnion(around payload: String, targetedAt destination: Destination) -> Promise { var guardSnode: Snode! var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination var encryptionResult: AESGCM.EncryptionResult! @@ -301,57 +325,67 @@ public enum OnionRequestAPI { } // MARK: Public API - /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { - let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] - return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in - guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } - throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error - } - } +// /// Sends an onion request to `snode`. Builds new paths as needed. +// public static func sendOnionRequest(to snode: Snode, invoking method: Snode.Method, with parameters: JSON, associatedWith publicKey: String? = nil) -> Promise { +// let payload: JSON = [ "method" : method.rawValue, "params" : parameters ] +// return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise in +// guard case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, let json, _) = error else { throw error } +// throw SnodeAPI.handleError(withStatusCode: statusCode, json: json, forSnode: snode, associatedWith: publicKey) ?? error +// } +// } /// Sends an onion request to `server`. Builds new paths as needed. - public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/loki/v3/lsrpc", using x25519PublicKey: String) -> Promise { + public static func sendOnionRequest(_ request: URLRequest, to server: String, target: String = "/oxen/v4/lsrpc", using x25519PublicKey: String) -> Promise<(ResponseInfo, Data?)> { + guard server == "https://chat.lokinet.dev" else { // TODO: Remove this + return LegacyOnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v3/lsrpc", using: x25519PublicKey) + } guard let url = request.url, let host = request.url?.host else { return Promise(error: Error.invalidURL) } - var headers: JSON = (request.allHTTPHeaderFields ?? [:]) - .mapValues { value -> Any in - switch value.lowercased() { - case "true": return true - case "false": return false - default: return value - } - } - .removingValue(forKey: "User-Agent") - // Note: We need to remove the leading forward slash unless we are explicitly hitting a legacy // endpoint (in which case we need it to ensure the request signing works correctly - // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints + // TODO: Confirm the 'removingPrefix' isn't going to break the request signing on non-legacy endpoints. let endpoint: String = url.path -// .removingPrefix("/", if: !url.path.starts(with: "/legacy")) .appending(url.query.map { value in "?\(value)" }) let scheme: String? = url.scheme let port: UInt16? = url.port.map { UInt16($0) } - let bodyAsString: String + + let requestInfo: RequestInfo = RequestInfo( + method: (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' + endpoint: endpoint, + headers: (request.allHTTPHeaderFields ?? [:]) + .setting( + "Content-Type", + // TODO: Determine what 'Content-Type' 'httpBodyStream' should have??? + (request.httpBody == nil && request.httpBodyStream == nil ? nil : + ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") // Default to JSON if not defined + ) + ) + .removingValue(forKey: "User-Agent") + ) + + guard let requestInfoData: Data = try? JSONEncoder().encode(requestInfo), let requestInfoString: String = String(data: requestInfoData, encoding: .ascii) else { + return Promise(error: Error.invalidRequestInfo) + } + + let payload: String if let body: Data = request.httpBody { - headers["Content-Type"] = "application/json" // Assume data is JSON - bodyAsString = (String(data: body, encoding: .utf8) ?? "null") + guard let bodyString: String = String(data: body, encoding: .ascii) else { + return Promise(error: Error.invalidRequestInfo) + } + + payload = "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" } - else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream) { - headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] - bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + else if let inputStream: InputStream = request.httpBodyStream, let body: Data = try? Data(from: inputStream), let bodyString: String = String(data: body, encoding: .ascii) { + // TODO: Handle this properly +// headers["Content-Type"] = request.allHTTPHeaderFields!["Content-Type"] +// bodyAsString = "{ \"fileUpload\" : \"\(String(data: body.base64EncodedData(), encoding: .utf8) ?? "null")\" }" + payload = "l\(requestInfoString.count):\(requestInfoString)\(bodyString.count):\(bodyString)e" } else { - bodyAsString = "null" + payload = "l\(requestInfoString.count):\(requestInfoString)e" } - let payload: JSON = [ - "body" : bodyAsString, - "endpoint" : endpoint, - "method" : (request.httpMethod ?? "GET"), // Default (if nil) is 'GET' - "headers" : headers - ] let destination = Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) let promise = sendOnionRequest(with: payload, to: destination) promise.catch2 { error in @@ -360,8 +394,8 @@ public enum OnionRequestAPI { return promise } - public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { - let (promise, seal) = Promise.pending() + public static func sendOnionRequest(with payload: String, to destination: Destination) -> Promise<(ResponseInfo, Data?)> { + let (promise, seal) = Promise<(ResponseInfo, Data?)>.pending() var guardSnode: Snode? Threading.workQueue.async { // Avoid race conditions on `guardSnodes` and `paths` buildOnion(around: payload, targetedAt: destination).done2 { intermediate in @@ -383,68 +417,78 @@ public enum OnionRequestAPI { } let destinationSymmetricKey = intermediate.destinationSymmetricKey - HTTP.execute(.post, url, body: body) - .done2 { json in - guard let base64EncodedIVAndCiphertext = json["result"] as? String, let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AESGCM.ivSize else { - return seal.reject(HTTP.Error.invalidJSON) - } + HTTP.updatedExecute(.post, url, body: body) + .done2 { responseData in + guard responseData.count >= AESGCM.ivSize else { return seal.reject(HTTP.Error.invalidResponse) } do { - let data = try AESGCM.decrypt(ivAndCiphertext, with: destinationSymmetricKey) + let data: Data = try AESGCM.decrypt(responseData, with: destinationSymmetricKey) - // The JSON data can be either an array or an object so can't cast to 'JSON' here - // TODO: Would be nice to ditch this 'JSONSerialization' behaviour if we can - guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) else { - return seal.reject(HTTP.Error.invalidJSON) + // The data will be in the form of `l123:jsone` or `l123:json456:bodye` so we need to break the data into + // parts to properly process it + guard let responseString: String = String(data: data, encoding: .ascii), responseString.starts(with: "l") else { + return seal.reject(HTTP.Error.invalidResponse) } - // TODO: How do we now handle this case when the `status_code` is out of sync now that the value isn't provided? - // TODO: Upgrade to V4? - var customStatusCode: Int = 200 + let stringParts: [String.SubSequence] = responseString.split(separator: ":") - if let json: JSON = jsonObject as? JSON, let bodyStatusCode: Int = (((json["status_code"] as? Int) ?? json["status"] as? Int) ?? json["code"] as? Int) { - guard bodyStatusCode != 406 else { - SNLog("The user's clock is out of sync with the service node network.") - return seal.reject(SnodeAPI.Error.clockOutOfSync) - } - - customStatusCode = bodyStatusCode + guard stringParts.count > 1, let infoLength: Int = Int(stringParts[0].suffix(from: stringParts[0].index(stringParts[0].startIndex, offsetBy: 1))) else { + return seal.reject(HTTP.Error.invalidResponse) } - if let json: JSON = jsonObject as? JSON, let bodyAsString: String = json["body"] as? String { - guard let bodyAsData = bodyAsString.data(using: .utf8), let body = try JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { - return seal.reject(HTTP.Error.invalidJSON) - } - - if let timestamp = body["t"] as? Int64 { - let offset = timestamp - Int64(NSDate.millisecondTimestamp()) - SnodeAPI.clockOffset = offset - } - - guard 200...299 ~= customStatusCode else { - return seal.reject( - Error.httpRequestFailedAtDestination( - statusCode: UInt(customStatusCode), - json: body, - destination: destination - ) - ) - } - - return seal.fulfill(data) + let infoStringStartIndex: String.Index = responseString.index(responseString.startIndex, offsetBy: "l\(infoLength):".count) + let infoStringEndIndex: String.Index = responseString.index(infoStringStartIndex, offsetBy: infoLength) + let infoString: String = String(responseString[infoStringStartIndex.. "l\(infoLength)\(infoString)e".count else { + return seal.fulfill((responseInfo, nil)) + } + + // TODO: Is this going to be done anymore...??? +// if let timestamp = body["t"] as? Int64 { +// let offset = timestamp - Int64(NSDate.millisecondTimestamp()) +// SnodeAPI.clockOffset = offset +// } + + // Extract the response data as well + let dataString: String = String(responseString.suffix(from: infoStringEndIndex)) + let dataStringParts: [String.SubSequence] = dataString.split(separator: ":") + + guard dataStringParts.count > 1, let finalDataLength: Int = Int(dataStringParts[0]) else { + return seal.reject(HTTP.Error.invalidResponse) + } + + let finalDataStringStartIndex: String.Index = responseString.index(infoStringEndIndex, offsetBy: "\(finalDataLength):".count) + let finalDataStringEndIndex: String.Index = responseString.index(finalDataStringStartIndex, offsetBy: finalDataLength) + let finalDataString: String = String(responseString[finalDataStringStartIndex.. RawResponsePromise { if Features.useOnionRequests { - return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } + // TODO: Ensure this should use the Legact request? + return LegacyOnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey).map2 { $0 as Any } } else { let url = "\(snode.address):\(snode.port)/storage_rpc/v1" return HTTP.execute(.post, url, parameters: parameters).map2 { $0 as Any }.recover2 { error -> Promise in diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 20ba08101..6d8b7d45b 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -80,12 +80,14 @@ public enum HTTP { case generic case httpRequestFailed(statusCode: UInt, json: JSON?) case invalidJSON + case invalidResponse public var errorDescription: String? { switch self { case .generic: return "An error occurred." case .httpRequestFailed(let statusCode, _): return "HTTP request failed with status code: \(statusCode)." case .invalidJSON: return "Invalid JSON." + case .invalidResponse: return "Invalid Response" } } } @@ -156,4 +158,46 @@ public enum HTTP { task.resume() return promise } + + // TODO: Consilidate the above and this method + public static func updatedExecute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = verb.rawValue + request.httpBody = body + request.timeoutInterval = timeout + request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent") + request.setValue("WhatsApp", forHTTPHeaderField: "User-Agent") // Set a fake value + request.setValue("en-us", forHTTPHeaderField: "Accept-Language") // Set a fake value + let (promise, seal) = Promise.pending() + let urlSession = useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession + let task = urlSession.dataTask(with: request) { data, response, error in + guard let data = data, let response = response as? HTTPURLResponse else { + if let error = error { + SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") + } else { + SNLog("\(verb.rawValue) request to \(url) failed.") + } + // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) + return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) + } + if let error = error { + SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") + // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) + return seal.reject(Error.httpRequestFailed(statusCode: 0, json: nil)) + } + let statusCode = UInt(response.statusCode) + + guard 200...299 ~= statusCode else { +// let jsonDescription = json?.prettifiedDescription ?? "no debugging info provided" +// SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") +// return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: json)) + // TODO: Provide error from backend here + return seal.reject(Error.httpRequestFailed(statusCode: statusCode, json: [:])) + } + + seal.fulfill(data) + } + task.resume() + return promise + } } diff --git a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift index 57ad21e43..d491b716f 100644 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift @@ -48,7 +48,8 @@ extension MessageSender { AttachmentUploadJob.upload( stream, using: { data in - OpenGroupAPIV2.upload( + // TODO: Update to non-legacy version + OpenGroupAPIV2.legacyUpload( data, to: v2OpenGroup.room, on: v2OpenGroup.server @@ -94,7 +95,8 @@ extension MessageSender { AttachmentUploadJob.upload( stream, using: { data in - OpenGroupAPIV2.upload( + // TODO: Update to non-legacy version + OpenGroupAPIV2.legacyUpload( data, to: v2OpenGroup.room, on: v2OpenGroup.server