Further SOGS V4 integration work

Added in the v4 onion requests logic
Added in the new pin/unpin APIs
Split up additional legacy methods to try and simplify the refactoring
Added a number of TODOs around usage of legacy request methods
This commit is contained in:
Morgan Pretty 2022-02-14 14:07:45 +11:00
parent 4f3900771e
commit c90f346d6a
18 changed files with 944 additions and 253 deletions

View File

@ -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 = "<group>"; };
FDC4387527B5BEF300C60D73 /* FileDownloadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloadResponse.swift; sourceTree = "<group>"; };
FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequest.swift; sourceTree = "<group>"; };
FDC4387B27B9C6C900C60D73 /* LegacyOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyOnionRequestAPI.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 */,

View File

@ -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

View File

@ -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)

View File

@ -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)
}

View File

@ -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<OpenGroupAPIV2.BatchResponse> {
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
}

View File

@ -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:)

View File

@ -156,7 +156,9 @@ public final class OpenGroupAPIV2: NSObject {
)
return send(request)
.then(on: OpenGroupAPIV2.workQueue) { data -> Promise<LegacyCompactPollResponse> in
.then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<LegacyCompactPollResponse> 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<Capabilities> {
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<Room> {
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<RoomPollInfo> {
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<Message> {
) -> 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<OnionRequestAPI.ResponseInfo> {
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<OnionRequestAPI.ResponseInfo> {
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<OnionRequestAPI.ResponseInfo> {
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<Data> = 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<FileUploadResponse> {
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<FileUploadResponse> {
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<Data> {
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<FileDownloadResponse> {
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<Data> {
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<Data> 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<LegacyCompactPollResponse> in
return legacySend(request)
.then(on: OpenGroupAPIV2.workQueue) { _, maybeData -> Promise<LegacyCompactPollResponse> 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<Data> = send(request).map(on: OpenGroupAPIV2.workQueue) { data in
let promise: Promise<Data> = 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<UInt64> {
public static func legacyUpload(_ file: Data, to room: String, on server: String) -> Promise<UInt64> {
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<Data> {
public static func legacyDownload(_ file: UInt64, from room: String, on server: String) -> Promise<Data> {
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<Void> {
public static func legacyDeleteMessage(with serverID: Int64, from room: String, on server: String) -> Promise<Void> {
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<Void> {
public static func legacyBan(_ publicKey: String, from room: String, on server: String) -> Promise<Void> {
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<Void> {
public static func legacyBanAndDeleteAllMessages(_ publicKey: String, from room: String, on server: String) -> Promise<Void> {
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<Void> {
public static func legacyUnban(_ publicKey: String, from room: String, on server: String) -> Promise<Void> {
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.")
}
}

View File

@ -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,

View File

@ -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<Void>.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

View File

@ -51,14 +51,17 @@ public final class PushNotificationAPI : NSObject {
request.httpBody = body
let promise: Promise<Void> = 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<Void> = 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<Void> = 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).")

View File

@ -47,25 +47,22 @@ public final class OpenGroupPollerV2 : NSObject {
let (promise, seal) = Promise<Void>.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

View File

@ -2,6 +2,7 @@
import Foundation
import PromiseKit
import SessionSnodeKit
extension Promise where T == Data {
func decoded<R: Decodable>(as type: R.Type, on queue: DispatchQueue? = nil, error: Error? = nil) -> Promise<R> {
@ -10,3 +11,15 @@ extension Promise where T == Data {
}
}
}
extension Promise where T == (OnionRequestAPI.ResponseInfo, Data?) {
func decoded<R: Decodable>(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))
}
}
}

View File

@ -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<Snode> = []
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<Void> {
let (promise, seal) = Promise<Void>.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<Set<Snode>> {
if guardSnodes.count >= targetGuardSnodeCount {
return Promise<Set<Snode>> { $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<Snode> {
// randomElement() uses the system's default random generator, which is cryptographically secure
guard let candidate = unusedSnodes.randomElement() else { return Promise<Snode> { $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<Path> {
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<OnionBuildingResult> {
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<AESGCM.EncryptionResult> in
guardSnode = path.first!
// Encrypt in reverse order, i.e. the destination first
return OnionRequestAPI.encrypt(payload, for: destination).then2 { r -> Promise<AESGCM.EncryptionResult> 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<AESGCM.EncryptionResult> {
if path.isEmpty {
return Promise<AESGCM.EncryptionResult> { $0.fulfill(encryptionResult) }
} else {
let lhs = OnionRequestAPI.Destination.snode(path.removeLast())
return OnionRequestAPI.encryptHop(from: lhs, to: rhs, using: encryptionResult).then2 { r -> Promise<AESGCM.EncryptionResult> 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<JSON> {
let payload: JSON = [ "method" : method.rawValue, "params" : parameters ]
return sendOnionRequest(with: payload, to: OnionRequestAPI.Destination.snode(snode)).recover2 { error -> Promise<JSON> 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<JSON> {
let (promise, seal) = Promise<JSON>.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)..<message.endIndex]
if let path = path, let snode = path.first(where: { $0.publicKeySet.ed25519Key == ed25519PublicKey }) {
var snodeFailureCount = LegacyOnionRequestAPI.snodeFailureCount[snode] ?? 0
snodeFailureCount += 1
if snodeFailureCount >= 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
}
}

View File

@ -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<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.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<AESGCM.EncryptionResult> {
let (promise, seal) = Promise<AESGCM.EncryptionResult>.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<AESGCM.EncryptionResult> {

View File

@ -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<OnionBuildingResult> {
private static func buildOnion(around payload: String, targetedAt destination: Destination) -> Promise<OnionBuildingResult> {
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<Data> {
let payload: JSON = [ "method" : method.rawValue, "params" : parameters ]
return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise<Data> 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<Data> {
// let payload: JSON = [ "method" : method.rawValue, "params" : parameters ]
// return sendOnionRequest(with: payload, to: Destination.snode(snode)).recover2 { error -> Promise<Data> 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<Data> {
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<Data> {
let (promise, seal) = Promise<Data>.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..<infoStringEndIndex])
guard let infoStringData: Data = infoString.data(using: .utf8), let responseInfo: ResponseInfo = try? JSONDecoder().decode(ResponseInfo.self, from: infoStringData) else {
return seal.reject(HTTP.Error.invalidResponse)
}
// Custom handle a clock out of sync error
guard responseInfo.code != 406 else {
SNLog("The user's clock is out of sync with the service node network.")
return seal.reject(SnodeAPI.Error.clockOutOfSync)
}
guard 200...299 ~= customStatusCode else {
// Handle error status codes
guard 200...299 ~= responseInfo.code else {
return seal.reject(
Error.httpRequestFailedAtDestination(
statusCode: UInt(customStatusCode),
json: json,
statusCode: UInt(responseInfo.code),
json: [:], // TODO: Remove the 'json' value??
destination: destination
)
)
}
seal.fulfill(data)
// If there is no data in the response then just return the ResponseInfo
guard responseString.count > "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..<finalDataStringEndIndex])
guard let finalData: Data = finalDataString.data(using: .ascii) else {
return seal.reject(HTTP.Error.invalidResponse)
}
return seal.fulfill((responseInfo, finalData))
}
catch {
seal.reject(error)

View File

@ -131,7 +131,8 @@ public final class SnodeAPI : NSObject {
// MARK: Internal API
internal static func invoke(_ method: Snode.Method, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> 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<Any> in

View File

@ -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<Data> {
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<Data>.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
}
}

View File

@ -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