// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SessionUtilitiesKit /// This lookup is created when the user interacts with a blinded id public struct BlindedIdLookup: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "blindedIdLookup" } public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case blindedId case sessionId case openGroupServer case openGroupPublicKey } public var id: String { blindedId } /// The blinded id for the user on this open group server public let blindedId: String /// The standard sessionId which can be used to generate this blindedId on this open group server /// /// **Note:** This value will be null if the user owning the blinded id hasn’t accepted the message request public let sessionId: String? /// The server for the Open Group server this blinded id belongs to public let openGroupServer: String /// The public key for the Open Group server this blinded id belongs to public let openGroupPublicKey: String // MARK: - Initialization public init( blindedId: String, sessionId: String? = nil, openGroupServer: String, openGroupPublicKey: String ) { self.blindedId = blindedId self.sessionId = sessionId self.openGroupServer = openGroupServer self.openGroupPublicKey = openGroupPublicKey } } // MARK: - Mutation public extension BlindedIdLookup { func with(sessionId: String) -> BlindedIdLookup { return BlindedIdLookup( blindedId: self.blindedId, sessionId: sessionId, openGroupServer: self.openGroupServer, openGroupPublicKey: self.openGroupPublicKey ) } } // MARK: - GRDB Interactions public extension BlindedIdLookup { /// Unfortunately the whole point of id-blinding is to make it hard to reverse-engineer a standard sessionId, as a result in order /// to see if there is an unblinded contact for this blindedId we can only really generate blinded ids for each contact and check /// if any match /// /// If we can't find a match this method will still store a lookup, just with no standard sessionId value (this gives us a method to /// link back to the open group the blindedId originated from) static func fetchOrCreate( _ db: Database, blindedId: String, sessionId: String? = nil, openGroupServer: String, openGroupPublicKey: String, isCheckingForOutbox: Bool, using dependencies: Dependencies = Dependencies() ) throws -> BlindedIdLookup { var lookup: BlindedIdLookup = (try? BlindedIdLookup .fetchOne(db, id: blindedId)) .defaulting( to: BlindedIdLookup( blindedId: blindedId, openGroupServer: openGroupServer.lowercased(), openGroupPublicKey: openGroupPublicKey ) ) // If the lookup already has a resolved sessionId then just return it immediately guard lookup.sessionId == nil else { return lookup } // If we we given a sessionId then validate it is correct and if so save it if let sessionId: String = sessionId, dependencies.crypto.verify( .sessionId( sessionId, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, using: dependencies ) ) { lookup = try lookup .with(sessionId: sessionId) .saved(db) return lookup } // We now need to try to match the blinded id to an existing contact, this can only be done by looping // through all approved contacts and generating a blinded id for the provided open group for each to // see if it matches the provided blindedId let contactsThatApprovedMeCursor: RecordCursor = try Contact .filter(Contact.Columns.didApproveMe == true) .fetchCursor(db) while let contact: Contact = try contactsThatApprovedMeCursor.next() { guard dependencies.crypto.verify( .sessionId( contact.id, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, using: dependencies ) ) else { continue } // We found a match so update the lookup and leave the loop lookup = try lookup .with(sessionId: contact.id) .saved(db) // There is an edge-case where the contact might not have their 'isApproved' flag set to true // but if we have a `BlindedIdLookup` for them and are performing the lookup from the outbox // then that means we sent them a message request and the 'isApproved' flag should be true if isCheckingForOutbox && !contact.isApproved { try Contact .filter(id: contact.id) .updateAllAndConfig(db, Contact.Columns.isApproved.set(to: true)) } break } // Finish if we have a result guard lookup.sessionId == nil else { return lookup } // Lastly loop through existing id lookups (in case the user is looking at a different SOGS but once had // a thread with this contact in a different SOGS and had cached the lookup) - we really should never hit // this case since the contact approval status is sync'ed (the only situation I can think of is a config // message hasn't been handled correctly?) let blindedIdLookupCursor: RecordCursor = try BlindedIdLookup .filter(BlindedIdLookup.Columns.sessionId != nil) .filter(BlindedIdLookup.Columns.openGroupServer != openGroupServer.lowercased()) .fetchCursor(db) while let otherLookup: BlindedIdLookup = try blindedIdLookupCursor.next() { guard let sessionId: String = otherLookup.sessionId, dependencies.crypto.verify( .sessionId( sessionId, matchesBlindedId: blindedId, serverPublicKey: openGroupPublicKey, using: dependencies ) ) else { continue } // We found a match so update the lookup and leave the loop lookup = try lookup .with(sessionId: sessionId) .saved(db) break } // Want to save the lookup even if it doesn't have a sessionId so it can be used when handling // MessageRequestResponse messages return try lookup .saved(db) } }