diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index d36be785d..f42af2a40 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ 94367C452C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 943C6D842B86B5F1004ACE64 /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D832B86B5F1004ACE64 /* Localization.swift */; }; + 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; 947AD6902C8968FF000B2730 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947AD68F2C8968FF000B2730 /* Constants.swift */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; @@ -2309,6 +2310,7 @@ buildActionMask = 2147483647; files = ( C3C2A6C62553896A00C340D1 /* SessionUtilitiesKit.framework in Frameworks */, + 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */, FD6673F82D7021F200041530 /* SessionUtil in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5248,6 +5250,7 @@ FD6A39672C2D283A00762359 /* XCRemoteSwiftPackageReference "session-ios-yyimage" */, FD6DA9D52D017F480092085A /* XCRemoteSwiftPackageReference "session-grdb-swift" */, FD756BEE2D06686500BD7199 /* XCRemoteSwiftPackageReference "session-lucide" */, + 946F5A712D5DA3AC00A5ADCE /* XCRemoteSwiftPackageReference "PunycodeSwift" */, FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */, ); productRefGroup = D221A08A169C9E5E00537ABF /* Products */; @@ -10169,6 +10172,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 946F5A712D5DA3AC00A5ADCE /* XCRemoteSwiftPackageReference "PunycodeSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gumob/PunycodeSwift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; @@ -10276,6 +10287,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 946F5A722D5DA3AC00A5ADCE /* Punycode */ = { + isa = XCSwiftPackageProductDependency; + package = 946F5A712D5DA3AC00A5ADCE /* XCRemoteSwiftPackageReference "PunycodeSwift" */; + productName = Punycode; + }; FD0150512CA2446D005B08A1 /* Quick */ = { isa = XCSwiftPackageProductDependency; package = FD6A39302C2AD33E00762359 /* XCRemoteSwiftPackageReference "Quick" */; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e3a76718a..d35983be3 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "688abd5f50453ed3433c8c6bf57bb60964d1c199902c2bff846544c6691cd18c", + "originHash" : "e3fdf2f44acd1f05dab295d0c9e3faf05f5e4461d512be1d5a77af42e0a25e48", "pins" : [ { "identity" : "cocoalumberjack", @@ -82,6 +82,15 @@ "version" : "5.2.0" } }, + { + "identity" : "punycodeswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gumob/PunycodeSwift.git", + "state" : { + "revision" : "30a462bdb4398ea835a3585472229e0d74b36ba5", + "version" : "3.0.0" + } + }, { "identity" : "quick", "kind" : "remoteSourceControl", diff --git a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist index b3bc8e8f6..ed2ccc9df 100644 --- a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist +++ b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist @@ -1225,6 +1225,33 @@ SOFTWARE. Title NVActivityIndicatorView + + License + MIT License + +Copyright (c) 2018 Gumob + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + Title + PunycodeSwift + License Apache License diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift new file mode 100644 index 000000000..22bcc18c1 --- /dev/null +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -0,0 +1,1172 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import Punycode +import SessionUtilitiesKit + +public extension Network.RequestType { + static func message( + _ message: SnodeMessage, + in namespace: SnodeAPI.Namespace + ) -> Network.RequestType { + return Network.RequestType(id: "snodeAPI.sendMessage", args: [message, namespace]) { + SnodeAPI.sendMessage(message, in: namespace, using: $0) + } + } +} + +public final class SnodeAPI { + /// The offset between the user's clock and the Service Node's clock. Used in cases where the + /// user's clock is incorrect. + /// + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + @ThreadSafe public static var clockOffsetMs: Int64 = 0 + + // MARK: - Hardfork version + + public static var hardfork = UserDefaults.standard[.hardfork] + public static var softfork = UserDefaults.standard[.softfork] + + // MARK: - Settings + + public static let maxRetryCount: Int = 8 + + public static func currentOffsetTimestampMs() -> Int64 { + return Int64( + Int64(floor(Date().timeIntervalSince1970 * 1000)) + + SnodeAPI.clockOffsetMs + ) + } + + // MARK: - Batching & Polling + + public typealias PollResponse = [SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)] + + public static func preparedPoll( + _ db: Database, + namespaces: [SnodeAPI.Namespace], + refreshingConfigHashes: [String] = [], + from snode: LibSession.Snode, + swarmPublicKey: String, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw SnodeAPIError.noKeyPair } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) + let namespaceLastHash: [SnodeAPI.Namespace: String] = try namespaces.reduce(into: [:]) { result, namespace in + guard namespace.shouldFetchSinceLastHash else { return } + + result[namespace] = try SnodeReceivedMessageInfo + .fetchLastNotExpired( + db, + for: snode, + namespace: namespace, + associatedWith: swarmPublicKey, + using: dependencies + )? + .hash + } + var requests: [any ErasedPreparedRequest] = [] + + // If we have any config hashes to refresh TTLs then add those requests first + if !refreshingConfigHashes.isEmpty { + requests.append( + try SnodeAPI.prepareRequest( + request: Request( + endpoint: .expire, + swarmPublicKey: swarmPublicKey, + body: UpdateExpiryRequest( + messageHashes: refreshingConfigHashes, + expiryMs: UInt64( + SnodeAPI.currentOffsetTimestampMs() + + (30 * 24 * 60 * 60 * 1000) // 30 days + ), + extend: true, + pubkey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + subkey: nil // TODO: Need to get this + ) + ), + responseType: UpdateExpiryResponse.self, + using: dependencies + ) + ) + } + + // Determine the maxSize each namespace in the request should take up + let namespaceMaxSizeMap: [SnodeAPI.Namespace: Int64] = SnodeAPI.Namespace.maxSizeMap(for: namespaces) + let fallbackSize: Int64 = (namespaceMaxSizeMap.values.min() ?? 1) + + // Add the various 'getMessages' requests + requests.append( + contentsOf: try namespaces.map { namespace -> any ErasedPreparedRequest in + // Check if this namespace requires authentication + guard namespace.requiresReadAuthentication else { + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .getMessages, + swarmPublicKey: swarmPublicKey, + body: LegacyGetMessagesRequest( + pubkey: swarmPublicKey, + lastHash: (namespaceLastHash[namespace] ?? ""), + namespace: namespace, + maxCount: nil, + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize) + ) + ), + responseType: GetMessagesResponse.self, + using: dependencies + ) + } + + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .getMessages, + swarmPublicKey: swarmPublicKey, + body: GetMessagesRequest( + lastHash: (namespaceLastHash[namespace] ?? ""), + namespace: namespace, + pubkey: swarmPublicKey, + subkey: nil, // TODO: Need to get this + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize) + ) + ), + responseType: GetMessagesResponse.self, + using: dependencies + ) + } + ) + + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .batch, + snode: snode, + swarmPublicKey: swarmPublicKey, + body: Network.BatchRequest(requestsKey: .requests, requests: requests) + ), + responseType: Network.BatchResponse.self, + requireAllBatchResponses: true, + using: dependencies + ) + .map { (_: ResponseInfoType, batchResponse: Network.BatchResponse) -> PollResponse in + let messageResponses: [Network.BatchSubResponse] = batchResponse + .compactMap { $0 as? Network.BatchSubResponse } + + /// Since we have extended the TTL for a number of messages we need to make sure we update the local + /// `SnodeReceivedMessageInfo.expirationDateMs` values so we don't end up deleting them + /// incorrectly before they actually expire on the swarm + if + !refreshingConfigHashes.isEmpty, + let refreshTTLSubReponse: Network.BatchSubResponse = batchResponse + .first(where: { $0 is Network.BatchSubResponse }) + .asType(Network.BatchSubResponse.self), + let refreshTTLResponse: UpdateExpiryResponse = refreshTTLSubReponse.body, + let validResults: [String: UpdateExpiryResponseResult] = try? refreshTTLResponse.validResultMap( + swarmPublicKey: getUserHexEncodedPublicKey(), + validationData: refreshingConfigHashes, + using: dependencies + ), + let targetResult: UpdateExpiryResponseResult = validResults[snode.ed25519PubkeyHex], + let groupedExpiryResult: [UInt64: [String]] = targetResult.changed + .updated(with: targetResult.unchanged) + .groupedByValue() + .nullIfEmpty() + { + dependencies.storage.writeAsync { db in + try groupedExpiryResult.forEach { updatedExpiry, hashes in + try SnodeReceivedMessageInfo + .filter(hashes.contains(SnodeReceivedMessageInfo.Columns.hash)) + .updateAll( + db, + SnodeReceivedMessageInfo.Columns.expirationDateMs + .set(to: updatedExpiry) + ) + } + } + } + + return zip(namespaces, messageResponses) + .reduce(into: [:]) { result, next in + guard let messageResponse: GetMessagesResponse = next.1.body else { return } + + let namespace: SnodeAPI.Namespace = next.0 + + result[namespace] = ( + info: next.1, + data: ( + messages: messageResponse.messages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: swarmPublicKey, + namespace: namespace, + rawMessage: rawMessage + ) + }, + lastHash: namespaceLastHash[namespace] + ) + ) + } + } + } + + public static func preparedSequence( + requests: [any ErasedPreparedRequest], + requireAllBatchResponses: Bool, + swarmPublicKey: String, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .sequence, + swarmPublicKey: swarmPublicKey, + body: Network.BatchRequest(requestsKey: .requests, requests: requests) + ), + responseType: Network.BatchResponse.self, + requireAllBatchResponses: requireAllBatchResponses, + using: dependencies + ) + } + + /// **Note:** This is the direct request to retrieve messages so should be retrieved automatically from the `poll()` method, in order to call + /// this directly remove the `@available` line + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + public static func getMessages( + _ db: Database, + in namespace: SnodeAPI.Namespace, + from snode: LibSession.Snode, + swarmPublicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<(info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?), Error> { + return Deferred { + Future { resolver in + let maybeLastHash: String? = try? SnodeReceivedMessageInfo + .fetchLastNotExpired( + db, + for: snode, + namespace: namespace, + associatedWith: swarmPublicKey, + using: dependencies + )? + .hash + + resolver(Result.success(maybeLastHash)) + } + } + .tryFlatMap { lastHash -> AnyPublisher<(info: ResponseInfoType, data: GetMessagesResponse?, lastHash: String?), Error> in + guard namespace.requiresReadAuthentication else { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .getMessages, + snode: snode, + swarmPublicKey: swarmPublicKey, + body: LegacyGetMessagesRequest( + pubkey: swarmPublicKey, + lastHash: (lastHash ?? ""), + namespace: namespace, + maxCount: nil, + maxSize: nil + ) + ), + responseType: GetMessagesResponse.self, + using: dependencies + ) + .send(using: dependencies) + .map { info, data in (info, data, lastHash) } + .eraseToAnyPublisher() + } + + guard let userED25519KeyPair: KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + throw SnodeAPIError.noKeyPair + } + + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .getMessages, + snode: snode, + swarmPublicKey: swarmPublicKey, + body: GetMessagesRequest( + lastHash: (lastHash ?? ""), + namespace: namespace, + pubkey: swarmPublicKey, + subkey: nil, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: GetMessagesResponse.self, + using: dependencies + ) + .send(using: dependencies) + .map { info, data in (info, data, lastHash) } + .eraseToAnyPublisher() + } + .map { info, data, lastHash -> (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?) in + return ( + info: info, + data: data.map { messageResponse -> (messages: [SnodeReceivedMessage], lastHash: String?) in + return ( + messages: messageResponse.messages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: swarmPublicKey, + namespace: namespace, + rawMessage: rawMessage + ) + }, + lastHash: lastHash + ) + } + ) + } + .eraseToAnyPublisher() + } + + public static func getSessionID( + for onsName: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + let validationCount = 3 + + // The name must be lowercased + let onsName = onsName.lowercased().idnaEncoded ?? onsName.lowercased() + + // Hash the ONS name using BLAKE2b + guard + let nameAsData: [UInt8] = onsName.data(using: .utf8).map({ Array($0) }), + let nameHash = dependencies.crypto.generate(.hash(message: nameAsData)) + else { + return Fail(error: SnodeAPIError.onsHashingFailed) + .eraseToAnyPublisher() + } + + // Ask 3 different snodes for the Session ID associated with the given name hash + let base64EncodedNameHash = nameHash.toBase64() + + return LibSession + .getRandomNodes(count: validationCount) + .tryFlatMap { nodes in + Publishers.MergeMany( + try nodes.map { snode in + try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .oxenDaemonRPCCall, + snode: snode, + body: OxenDaemonRPCRequest( + endpoint: .daemonOnsResolve, + body: ONSResolveRequest( + type: 0, // type 0 means Session + base64EncodedNameHash: base64EncodedNameHash + ) + ) + ), + responseType: ONSResolveResponse.self, + using: dependencies + ) + .tryMap { _, response -> String in + try dependencies.crypto.tryGenerate( + .sessionId(name: onsName, response: response) + ) + } + .send(using: dependencies) + .map { _, sessionId in sessionId } + .eraseToAnyPublisher() + } + ) + } + .collect() + .tryMap { results -> String in + guard results.count == validationCount, Set(results).count == 1 else { + throw SnodeAPIError.onsValidationFailed + } + + return results[0] + } + .eraseToAnyPublisher() + } + + public static func getExpiries( + swarmPublicKey: String, + of serverHashes: [String], + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<(ResponseInfoType, GetExpiriesResponse), Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let sendTimestamp: UInt64 = UInt64(SnodeAPI.currentOffsetTimestampMs()) + + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .getExpiries, + swarmPublicKey: swarmPublicKey, + body: GetExpiriesRequest( + messageHashes: serverHashes, + pubkey: swarmPublicKey, + subkey: nil, + timestampMs: sendTimestamp, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: GetExpiriesResponse.self, + using: dependencies + ) + .send(using: dependencies) + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + // MARK: - Store + + public static func preparedSendMessage( + _ db: Database, + message: SnodeMessage, + in namespace: Namespace, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let swarmPublicKey: String = message.recipient + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + let request: Network.PreparedRequest = try { + // Check if this namespace requires authentication + guard namespace.requiresWriteAuthentication else { + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: swarmPublicKey, + body: LegacySendMessagesRequest( + message: message, + namespace: namespace + ) + ), + responseType: SendMessagesResponse.self, + requestAndPathBuildTimeout: Network.defaultTimeout, + using: dependencies + ) + } + + guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + throw SnodeAPIError.noKeyPair + } + + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: swarmPublicKey, + body: SendMessageRequest( + message: message, + namespace: namespace, + subkey: nil, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: SendMessagesResponse.self, + requestAndPathBuildTimeout: Network.defaultTimeout, + using: dependencies + ) + }() + + return request + .tryMap { _, response -> SendMessagesResponse in + try response.validResultMap( + swarmPublicKey: userX25519PublicKey, + using: dependencies + ) + + return response + } + } + + public static func sendMessage( + _ message: SnodeMessage, + in namespace: Namespace, + using dependencies: Dependencies + ) -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> { + let swarmPublicKey: String = message.recipient + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + do { + let request: Network.PreparedRequest = try { + // Check if this namespace requires authentication + guard namespace.requiresWriteAuthentication else { + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: swarmPublicKey, + body: LegacySendMessagesRequest( + message: message, + namespace: namespace + ), + retryCount: 0 // The SendMessageJob already has a retry mechanism + ), + responseType: SendMessagesResponse.self, + requestAndPathBuildTimeout: Network.defaultTimeout, + using: dependencies + ) + } + + guard let userED25519KeyPair: KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + throw SnodeAPIError.noKeyPair + } + + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: swarmPublicKey, + body: SendMessageRequest( + message: message, + namespace: namespace, + subkey: nil, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ), + retryCount: 0 // The SendMessageJob already has a retry mechanism + ), + responseType: SendMessagesResponse.self, + requestAndPathBuildTimeout: Network.defaultTimeout, + using: dependencies + ) + }() + + return request + .tryMap { info, response -> SendMessagesResponse in + try response.validResultMap( + swarmPublicKey: userX25519PublicKey, + using: dependencies + ) + + return response + } + .send(using: dependencies) + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + public static func sendConfigMessages( + _ messages: [(message: SnodeMessage, namespace: Namespace)], + allObsoleteHashes: [String], + swarmPublicKey: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + guard !messages.isEmpty || !allObsoleteHashes.isEmpty else { + return Fail(error: NetworkError.invalidPreparedRequest) + .eraseToAnyPublisher() + } + // TODO: Need to get either the closed group subKey or the userEd25519 key for auth + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + do { + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + var requests: [any ErasedPreparedRequest] = try messages + .map { message, namespace in + // Check if this namespace requires authentication + guard namespace.requiresWriteAuthentication else { + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: swarmPublicKey, + body: LegacySendMessagesRequest( + message: message, + namespace: namespace + ) + ), + responseType: SendMessagesResponse.self, + using: dependencies + ) + } + + return try SnodeAPI.prepareRequest( + request: Request( + endpoint: .sendMessage, + swarmPublicKey: swarmPublicKey, + body: SendMessageRequest( + message: message, + namespace: namespace, + subkey: nil, // TODO: Need to get this + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: SendMessagesResponse.self, + using: dependencies + ) + } + + // If we had any previous config messages then we should delete them + if !allObsoleteHashes.isEmpty { + requests.append( + try SnodeAPI.prepareRequest( + request: Request( + endpoint: .deleteMessages, + swarmPublicKey: swarmPublicKey, + body: DeleteMessagesRequest( + messageHashes: allObsoleteHashes, + requireSuccessfulDeletion: false, + swarmPublicKey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: DeleteMessagesResponse.self, + using: dependencies + ) + ) + } + + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .sequence, + swarmPublicKey: swarmPublicKey, + body: Network.BatchRequest(requestsKey: .requests, requests: requests) + ), + responseType: Network.BatchResponse.self, + requireAllBatchResponses: false, + requestAndPathBuildTimeout: Network.defaultTimeout, + using: dependencies + ) + .send(using: dependencies) + .map { _, response in response } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + // MARK: - Edit + + public static func updateExpiry( + swarmPublicKey: String, + serverHashes: [String], + updatedExpiryMs: Int64, + shortenOnly: Bool? = nil, + extendOnly: Bool? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<[String: UpdateExpiryResponseResult], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + // ShortenOnly and extendOnly cannot be true at the same time + guard shortenOnly == nil || extendOnly == nil else { + return Fail(error: NetworkError.invalidPreparedRequest) + .eraseToAnyPublisher() + } + + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .expire, + swarmPublicKey: swarmPublicKey, + body: UpdateExpiryRequest( + messageHashes: serverHashes, + expiryMs: UInt64(updatedExpiryMs), + shorten: shortenOnly, + extend: extendOnly, + pubkey: swarmPublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + subkey: nil + ) + ), + responseType: UpdateExpiryResponse.self, + using: dependencies + ) + .send(using: dependencies) + .tryMap { _, response -> [String: UpdateExpiryResponseResult] in + try response.validResultMap( + swarmPublicKey: getUserHexEncodedPublicKey(), + validationData: serverHashes, + using: dependencies + ) + } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + public static func revokeSubkey( + swarmPublicKey: String, + subkeyToRevoke: String, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .revokeSubaccount, + swarmPublicKey: swarmPublicKey, + body: RevokeSubkeyRequest( + subkeyToRevoke: subkeyToRevoke, + pubkey: swarmPublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: RevokeSubkeyResponse.self, + using: dependencies + ) + .send(using: dependencies) + .tryMap { _, response -> Void in + _ = try response.validResultMap( + swarmPublicKey: getUserHexEncodedPublicKey(), + validationData: subkeyToRevoke, + using: dependencies + ) + + return () + } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + // MARK: Delete + + public static func preparedDeleteMessages( + _ db: Database, + swarmPublicKey: String, + serverHashes: [String], + requireSuccessfulDeletion: Bool, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest<[String: Bool]> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw SnodeAPIError.noKeyPair } + + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .deleteMessages, + swarmPublicKey: swarmPublicKey, + body: DeleteMessagesRequest( + messageHashes: serverHashes, + requireSuccessfulDeletion: requireSuccessfulDeletion, + swarmPublicKey: swarmPublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: DeleteMessagesResponse.self, + using: dependencies + ) + .tryMap { _, response -> [String: Bool] in + let validResultMap: [String: Bool] = try response.validResultMap( + swarmPublicKey: swarmPublicKey, + validationData: serverHashes, + using: dependencies + ) + + // If `validResultMap` didn't throw then at least one service node + // deleted successfully so we should mark the hash as invalid so we + // don't try to fetch updates using that hash going forward (if we + // do we would end up re-fetching all old messages) + Storage.shared.writeAsync { db in + try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: serverHashes + ) + } + + return validResultMap + } + } + + public static func deleteMessages( + swarmPublicKey: String, + serverHashes: [String], + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<[String: Bool], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey(using: dependencies) + + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .deleteMessages, + swarmPublicKey: swarmPublicKey, + body: DeleteMessagesRequest( + messageHashes: serverHashes, + requireSuccessfulDeletion: false, + swarmPublicKey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: DeleteMessagesResponse.self, + using: dependencies + ) + .send(using: dependencies) + .tryMap { _, response -> [String: Bool] in + let validResultMap: [String: Bool] = try response.validResultMap( + swarmPublicKey: userX25519PublicKey, + validationData: serverHashes, + using: dependencies + ) + + // If `validResultMap` didn't throw then at least one service node + // deleted successfully so we should mark the hash as invalid so we + // don't try to fetch updates using that hash going forward (if we + // do we would end up re-fetching all old messages) + Storage.shared.writeAsync { db in + try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: serverHashes + ) + } + + return validResultMap + } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. + public static func deleteAllMessages( + namespace: SnodeAPI.Namespace, + requestTimeout: TimeInterval = Network.defaultTimeout, + requestAndPathBuildTimeout: TimeInterval? = nil, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<[String: Bool], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .deleteAll, + swarmPublicKey: userX25519PublicKey, + requiresLatestNetworkTime: true, + body: DeleteAllMessagesRequest( + namespace: namespace, + pubkey: userX25519PublicKey, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ), + retryCount: 0 // Don't auto retry this request (user can manually retry on failure) + ), + responseType: DeleteAllMessagesResponse.self, + requestTimeout: requestTimeout, + requestAndPathBuildTimeout: requestAndPathBuildTimeout, + using: dependencies + ) + .send(using: dependencies) + .tryMap { info, response -> [String: Bool] in + guard let targetInfo: LatestTimestampResponseInfo = info as? LatestTimestampResponseInfo else { + throw NetworkError.invalidResponse + } + + return try response.validResultMap( + swarmPublicKey: userX25519PublicKey, + validationData: targetInfo.timestampMs, + using: dependencies + ) + } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. + public static func deleteAllMessages( + beforeMs: UInt64, + namespace: SnodeAPI.Namespace, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<[String: Bool], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .deleteAllBefore, + swarmPublicKey: userX25519PublicKey, + requiresLatestNetworkTime: true, + body: DeleteAllBeforeRequest( + beforeMs: beforeMs, + namespace: namespace, + pubkey: userX25519PublicKey, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ), + retryCount: 0 // Don't auto retry this request (user can manually retry on failure) + ), + responseType: DeleteAllBeforeResponse.self, + using: dependencies + ) + .send(using: dependencies) + .tryMap { _, response -> [String: Bool] in + try response.validResultMap( + swarmPublicKey: userX25519PublicKey, + validationData: beforeMs, + using: dependencies + ) + } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + // MARK: - Internal API + + public static func getNetworkTime( + from snode: LibSession.Snode, + requestTimeout: TimeInterval = Network.defaultTimeout, + requestAndPathBuildTimeout: TimeInterval? = nil, + using dependencies: Dependencies + ) -> AnyPublisher { + do { + return try SnodeAPI + .prepareRequest( + request: Request( + endpoint: .getInfo, + snode: snode, + body: [String: String]() + ), + responseType: GetNetworkTimestampResponse.self, + requestTimeout: requestTimeout, + requestAndPathBuildTimeout: requestAndPathBuildTimeout, + using: dependencies + ) + .send(using: dependencies) + .map { _, response in + // Assume we've fetched the networkTime in order to send a message to the specified snode, in + // which case we want to update the 'clockOffsetMs' value for subsequent requests + let offset = (Int64(response.timestamp) - Int64(floor(dependencies.dateNow.timeIntervalSince1970 * 1000))) + SnodeAPI.clockOffsetMs = offset + + return response.timestamp + } + .eraseToAnyPublisher() + } + catch { return Fail(error: error).eraseToAnyPublisher() } + } + + // MARK: - Convenience + + private static func prepareRequest( + request: Request, + responseType: R.Type, + requireAllBatchResponses: Bool = true, + retryCount: Int = 0, + requestTimeout: TimeInterval = Network.defaultTimeout, + requestAndPathBuildTimeout: TimeInterval? = nil, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return Network.PreparedRequest( + request: request, + urlRequest: try request.generateUrlRequest(using: dependencies), + responseType: responseType, + requireAllBatchResponses: requireAllBatchResponses, + retryCount: retryCount, + requestTimeout: requestTimeout, + requestAndPathBuildTimeout: requestAndPathBuildTimeout + ) + .handleEvents( + receiveOutput: { _, response in + switch response { + case let snodeResponse as SnodeResponse: + // Update the network offset based on the response so subsequent requests have + // the correct network offset time + let offset = (Int64(snodeResponse.timeOffset) - Int64(floor(dependencies.dateNow.timeIntervalSince1970 * 1000))) + SnodeAPI.clockOffsetMs = offset + + // Extract and store hard fork information if returned + guard snodeResponse.hardFork.count > 1 else { break } + + if snodeResponse.hardFork[1] > softfork { + softfork = snodeResponse.hardFork[1] + UserDefaults.standard[.softfork] = softfork + } + + if snodeResponse.hardFork[0] > hardfork { + hardfork = snodeResponse.hardFork[0] + UserDefaults.standard[.hardfork] = hardfork + softfork = snodeResponse.hardFork[1] + UserDefaults.standard[.softfork] = softfork + } + + default: break + } + } + ) + } +} + +// MARK: - Publisher Convenience + +public extension Publisher where Output == Set { + func tryFlatMapWithRandomSnode( + maxPublishers: Subscribers.Demand = .unlimited, + retry retries: Int = 0, + drainBehaviour: ThreadSafeObject = .alwaysRandom, + using dependencies: Dependencies, + _ transform: @escaping (LibSession.Snode) throws -> P + ) -> AnyPublisher where T == P.Output, P: Publisher, P.Failure == Error { + return self + .mapError { $0 } + .flatMap(maxPublishers: maxPublishers) { swarm -> AnyPublisher in + // If we don't want to reuse a specific snode multiple times then just grab a + // random one from the swarm every time + var remainingSnodes: Set = drainBehaviour.performUpdateAndMap { behaviour in + switch behaviour { + case .alwaysRandom: return (behaviour, swarm) + case .limitedReuse(_, let targetSnode, _, let usedSnodes, let swarmHash): + // If we've used all of the snodes or the swarm has changed then reset the used list + guard swarmHash == swarm.hashValue && (targetSnode != nil || usedSnodes != swarm) else { + return (behaviour.reset(), swarm) + } + + return (behaviour, swarm.subtracting(usedSnodes)) + } + } + var lastError: Error? + + return Just(()) + .setFailureType(to: Error.self) + .tryFlatMap(maxPublishers: maxPublishers) { _ -> AnyPublisher in + let snode: LibSession.Snode = try drainBehaviour.performUpdateAndMap { behaviour in + switch behaviour { + case .limitedReuse(_, .some(let targetSnode), _, _, _): + return (behaviour.use(snode: targetSnode, from: swarm), targetSnode) + default: break + } + + // Select the next snode + let result: LibSession.Snode = try dependencies.popRandomElement(&remainingSnodes) ?? { + throw SnodeAPIError.ranOutOfRandomSnodes(lastError) + }() + + return (behaviour.use(snode: result, from: swarm), result) + } + + return try transform(snode) + .eraseToAnyPublisher() + } + .mapError { error in + // Prevent nesting the 'ranOutOfRandomSnodes' errors + switch error { + case SnodeAPIError.ranOutOfRandomSnodes: break + default: lastError = error + } + + return error + } + .retry(retries) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + +// MARK: - Request Convenience + +private extension Request { + init( + endpoint: SnodeAPI.Endpoint, + swarmPublicKey: String, + body: B, + retryCount: Int = SnodeAPI.maxRetryCount + ) where T == SnodeRequest, Endpoint == SnodeAPI.Endpoint { + self = Request( + method: .post, + endpoint: endpoint, + swarmPublicKey: swarmPublicKey, + body: SnodeRequest( + endpoint: endpoint, + body: body + ), + retryCount: retryCount + ) + } + + init( + endpoint: SnodeAPI.Endpoint, + snode: LibSession.Snode, + swarmPublicKey: String? = nil, + body: B, + retryCount: Int = SnodeAPI.maxRetryCount + ) where T == SnodeRequest, Endpoint == SnodeAPI.Endpoint { + self = Request( + method: .post, + endpoint: endpoint, + snode: snode, + body: SnodeRequest( + endpoint: endpoint, + body: body + ), + swarmPublicKey: swarmPublicKey, + retryCount: retryCount + ) + } + + init( + endpoint: SnodeAPI.Endpoint, + swarmPublicKey: String, + requiresLatestNetworkTime: Bool, + body: B, + retryCount: Int + ) where T == SnodeRequest, Endpoint == SnodeAPI.Endpoint, B: Encodable & UpdatableTimestamp { + self = Request( + method: .post, + endpoint: endpoint, + swarmPublicKey: swarmPublicKey, + requiresLatestNetworkTime: requiresLatestNetworkTime, + body: SnodeRequest( + endpoint: endpoint, + body: body + ), + retryCount: retryCount + ) + } +} diff --git a/SessionSnodeKit/SnodeAPI/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI/SnodeAPI.swift index d2202a84f..e818cee76 100644 --- a/SessionSnodeKit/SnodeAPI/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI/SnodeAPI.swift @@ -5,6 +5,7 @@ import Foundation import Combine import GRDB +import Punycode import SessionUtilitiesKit public final class SnodeAPI { @@ -226,7 +227,7 @@ public final class SnodeAPI { let validationCount = 3 // The name must be lowercased - let onsName = onsName.lowercased() + let onsName = onsName.lowercased().idnaEncoded ?? onsName.lowercased() // Hash the ONS name using BLAKE2b guard