diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 264ee7a7e..cd0948d44 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -306,6 +306,10 @@ class ThreadSettingsViewModel: SessionTableViewModel = try decoder.container(keyedBy: CodingKeys.self) updated = ((try? container.decode([String].self, forKey: .updated)) ?? []) + unchanged = ((try? container.decode([String: UInt64].self, forKey: .unchanged)) ?? [:]) expiry = try? container.decode(UInt64.self, forKey: .expiry) try super.init(from: decoder) @@ -35,7 +38,7 @@ public extension UpdateExpiryResponse { extension UpdateExpiryResponse: ValidatableResponse { typealias ValidationData = [String] - typealias ValidationResponse = (hashes: [String], expiry: UInt64) + typealias ValidationResponse = [(hash: String, expiry: UInt64)] /// All responses in the swarm must be valid internal static var requiredSuccessfulResponses: Int { -1 } @@ -44,14 +47,15 @@ extension UpdateExpiryResponse: ValidatableResponse { sodium: Sodium, userX25519PublicKey: String, validationData: [String] - ) throws -> [String: (hashes: [String], expiry: UInt64)] { - let validationMap: [String: (hashes: [String], expiry: UInt64)] = try swarm.reduce(into: [:]) { result, next in + ) throws -> [String: [(hash: String, expiry: UInt64)]] { + let validationMap: [String: [(hash: String, expiry: UInt64)]] = try swarm.reduce(into: [:]) { result, next in guard !next.value.failed, + let appliedExpiry: UInt64 = next.value.expiry, let signatureBase64: String = next.value.signatureBase64, let encodedSignature: Data = Data(base64Encoded: signatureBase64) else { - result[next.key] = ([], 0) + result[next.key] = [] if let reason: String = next.value.reason, let statusCode: Int = next.value.code { SNLog("Couldn't update expiry from: \(next.key) due to error: \(reason) (\(statusCode)).") @@ -63,13 +67,24 @@ extension UpdateExpiryResponse: ValidatableResponse { } /// Signature of - /// `( PUBKEY_HEX || EXPIRY || RMSG[0] || ... || RMSG[N] || UMSG[0] || ... || UMSG[M] )` - /// where RMSG are the requested expiry hashes and UMSG are the actual updated hashes. The signature uses - /// the node's ed25519 pubkey. + /// `( PUBKEY_HEX || EXPIRY || RMSGs... || UMSGs... || CMSG_EXPs... )` + /// where RMSGs are the requested expiry hashes, UMSGs are the actual updated hashes, and + /// CMSG_EXPs are (HASH || EXPIRY) values, ascii-sorted by hash, for the unchanged message + /// hashes included in the "unchanged" field. The signature uses the node's ed25519 pubkey. + /// + /// **Note:** If `updated` is empty then the `expiry` value will match the value that was + /// included in the original request let verificationBytes: [UInt8] = userX25519PublicKey.bytes - .appending(contentsOf: "\(String(describing: next.value.expiry))".data(using: .ascii)?.bytes) + .appending(contentsOf: "\(appliedExpiry)".data(using: .ascii)?.bytes) .appending(contentsOf: validationData.joined().bytes) - .appending(contentsOf: next.value.updated.joined().bytes) + .appending(contentsOf: next.value.updated.sorted().joined().bytes) + .appending(contentsOf: next.value.unchanged + .sorted(by: { lhs, rhs in lhs.key < rhs.key }) + .reduce(into: [UInt8]()) { result, nextUnchanged in + result.append(contentsOf: nextUnchanged.key.bytes) + result.append(contentsOf: "\(nextUnchanged.value)".data(using: .ascii)?.bytes ?? []) + } + ) let isValid: Bool = sodium.sign.verify( message: verificationBytes, publicKey: Data(hex: next.key).bytes, @@ -79,11 +94,9 @@ extension UpdateExpiryResponse: ValidatableResponse { // If the update signature is invalid then we want to fail here guard isValid else { throw SnodeAPIError.signatureVerificationFailed } - // If we didn't get an `expiry` value from the snode then don't bother adding it to the result - // as it's not valid data - guard let expiry: UInt64 = next.value.expiry else { return } - - result[next.key] = (hashes: next.value.updated, expiry: expiry) + result[next.key] = next.value.updated + .map { ($0, appliedExpiry) } + .appending(contentsOf: next.value.unchanged.map { ($0.key, $0.value) }) } return try Self.validated(map: validationMap, totalResponseCount: swarm.count) diff --git a/SessionSnodeKit/Networking/SnodeAPI.swift b/SessionSnodeKit/Networking/SnodeAPI.swift index 82590eb42..ee578452a 100644 --- a/SessionSnodeKit/Networking/SnodeAPI.swift +++ b/SessionSnodeKit/Networking/SnodeAPI.swift @@ -409,10 +409,42 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: responseTypes, using: dependencies) - .map { batchResponse -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)] in + .map { (batchResponse: HTTP.BatchResponse) -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)] in let messageResponses: [HTTP.BatchSubResponse] = batchResponse.responses .compactMap { $0 as? HTTP.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: HTTP.BatchSubResponse = batchResponse + .responses + .first(where: { $0 is HTTP.BatchSubResponse }) + .asType(HTTP.BatchSubResponse.self), + let refreshTTLResponse: UpdateExpiryResponse = refreshTTLSubReponse.body, + let validResults: [String: [(hash: String, expiry: UInt64)]] = try? refreshTTLResponse.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: getUserHexEncodedPublicKey(), + validationData: refreshingConfigHashes + ), + let groupedExpiryResult: [UInt64: [String]] = validResults[snode.ed25519PublicKey]? + .grouped(by: \.expiry) + .mapValues({ groupedResults in groupedResults.map { $0.hash } }) + { + Storage.shared.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 } @@ -720,7 +752,7 @@ public final class SnodeAPI { serverHashes: [String], updatedExpiryMs: UInt64, using dependencies: SSKDependencies = SSKDependencies() - ) -> AnyPublisher<[String: (hashes: [String], expiry: UInt64)], Error> { + ) -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> { guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { return Fail(error: SnodeAPIError.noKeyPair) .eraseToAnyPublisher() @@ -728,7 +760,7 @@ public final class SnodeAPI { return getSwarm(for: publicKey) .subscribe(on: Threading.workQueue) - .tryFlatMap { swarm -> AnyPublisher<[String: (hashes: [String], expiry: UInt64)], Error> in + .tryFlatMap { swarm -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> in guard let snode: Snode = swarm.randomElement() else { throw SnodeAPIError.generic } return SnodeAPI @@ -749,7 +781,7 @@ public final class SnodeAPI { using: dependencies ) .decoded(as: UpdateExpiryResponse.self, using: dependencies) - .tryMap { _, response -> [String: (hashes: [String], expiry: UInt64)] in + .tryMap { _, response -> [String: [(hash: String, expiry: UInt64)]] in try response.validResultMap( sodium: sodium.wrappedValue, userX25519PublicKey: getUserHexEncodedPublicKey(),