Started working on some config contact pruning logic

Added support for a ConfirmationModal with an input field
Added a mechanism on Debug builds to export the database and it's key
Added logic to catch exceptions thrown within libSession (need to actually plug it in)
Added a debug-only mechanism to export the users database and (encrypted) database key
Added a few unit tests to check the CONTACTS config message size constraints
This commit is contained in:
Morgan Pretty 2023-06-16 19:38:14 +10:00
parent 65bf7d7d82
commit d2c82cb915
19 changed files with 2375 additions and 1530 deletions

View File

@ -0,0 +1,20 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
let arguments = CommandLine.arguments
// First argument is the file name
if arguments.count == 3 {
let encryptedData = Data(base64Encoded: arguments[1].data(using: .utf8)!)!
let hash: SHA256.Digest = SHA256.hash(data: arguments[2].data(using: .utf8)!)
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
let sealedBox = try! ChaChaPoly.SealedBox(combined: encryptedData)
let decryptedData = try! ChaChaPoly.open(sealedBox, using: key)
print(Array(decryptedData).map { String(format: "%02x", $0) }.joined())
}
else {
print("Please provide the base64 encoded 'encrypted key' and plain text 'password' as arguments")
}

View File

@ -588,6 +588,8 @@
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; }; FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; };
FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */; }; FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */; };
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */; settings = {ATTRIBUTES = (Public, ); }; };
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */; };
FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */; }; FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */; };
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; }; FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; };
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; }; FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
@ -1727,6 +1729,8 @@
FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = "<group>"; }; FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = "<group>"; };
FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigMessageReceiveJob.swift; sourceTree = "<group>"; }; FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigMessageReceiveJob.swift; sourceTree = "<group>"; };
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CExceptionHelper.h; sourceTree = "<group>"; };
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CExceptionHelper.mm; sourceTree = "<group>"; };
FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; }; FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = "<group>"; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = "<group>"; };
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; }; FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
@ -3589,6 +3593,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
FD3003692A3ADD6000B5A5FB /* CExceptionHelper.h */,
FD30036D2A3AE26000B5A5FB /* CExceptionHelper.mm */,
FD09796A27F6C67500936362 /* Failable.swift */, FD09796A27F6C67500936362 /* Failable.swift */,
FD09797127FAA2F500936362 /* Optional+Utilities.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
@ -4457,6 +4463,7 @@
B8856E1A256F1700001CE70E /* OWSMath.h in Headers */, B8856E1A256F1700001CE70E /* OWSMath.h in Headers */,
C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */, C352A3772557864000338F3E /* NSTimer+Proxying.h in Headers */,
C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */, C32C5B51256DC219003C73A2 /* NSNotificationCenter+OWS.h in Headers */,
FD30036A2A3ADEC100B5A5FB /* CExceptionHelper.h in Headers */,
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */, C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */, C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */, B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
@ -5639,6 +5646,7 @@
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */, FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */, FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */,

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation import Foundation
import CryptoKit
import GRDB import GRDB
import DifferenceKit import DifferenceKit
import SessionUIKit import SessionUIKit
@ -9,6 +10,10 @@ import SessionUtilitiesKit
import SignalCoreKit import SignalCoreKit
class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> { class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpViewModel.Section> {
#if DEBUG
private var databaseKeyEncryptionPassword: String = ""
#endif
// MARK: - Section // MARK: - Section
public enum Section: SessionTableSection { public enum Section: SessionTableSection {
@ -17,6 +22,9 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
case feedback case feedback
case faq case faq
case support case support
#if DEBUG
case exportDatabase
#endif
var style: SessionTableSectionStyle { .padding } var style: SessionTableSectionStyle { .padding }
} }
@ -136,6 +144,28 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
] ]
) )
] ]
#if DEBUG
.appending(
SectionModel(
model: .exportDatabase,
elements: [
SessionCell.Info(
id: .support,
title: "Export Database",
rightAccessory: .icon(
UIImage(systemName: "square.and.arrow.up.trianglebadge.exclamationmark")?
.withRenderingMode(.alwaysTemplate),
size: .small
),
styling: SessionCell.StyleInfo(
tintColor: .danger
),
onTapView: { [weak self] view in self?.exportDatabase(view) }
)
]
)
)
#endif
} }
.removeDuplicates() .removeDuplicates()
.publisher(in: Storage.shared) .publisher(in: Storage.shared)
@ -197,4 +227,132 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
showShareSheet() showShareSheet()
} }
} }
#if DEBUG
private func exportDatabase(_ targetView: UIView?) {
let generatedPassword: String = UUID().uuidString
self.databaseKeyEncryptionPassword = generatedPassword
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Export Database",
body: .input(
explanation: NSAttributedString(
string: """
Sharing the database and key together is dangerous!
We've generated a secure password for you but feel free to provide your own (we will show the generated password again after exporting)
This password will be used to encrypt the database decryption key and will be exported alongside the database
"""
),
placeholder: "Enter a password",
initialValue: generatedPassword,
clearButton: true,
onChange: { [weak self] value in self?.databaseKeyEncryptionPassword = value }
),
confirmTitle: "Export",
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
modal.dismiss(animated: true) {
guard let password: String = self?.databaseKeyEncryptionPassword, password.count >= 6 else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Error",
body: .text("Password must be at least 6 characters")
)
),
transitionType: .present
)
return
}
do {
let exportInfo = try Storage.shared.exportInfo(password: password)
let shareVC = UIActivityViewController(
activityItems: [
URL(fileURLWithPath: exportInfo.dbPath),
URL(fileURLWithPath: exportInfo.keyPath)
],
applicationActivities: nil
)
shareVC.completionWithItemsHandler = { [weak self] _, completed, _, _ in
guard
completed &&
generatedPassword == self?.databaseKeyEncryptionPassword
else { return }
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Password",
body: .text("""
The generated password was:
\(generatedPassword)
Avoid sending this via the same means as the database
"""),
confirmTitle: "Share",
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
modal.dismiss(animated: true) {
let passwordShareVC = UIActivityViewController(
activityItems: [generatedPassword],
applicationActivities: nil
)
if UIDevice.current.isIPad {
passwordShareVC.excludedActivityTypes = []
passwordShareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : [])
passwordShareVC.popoverPresentationController?.sourceView = targetView
passwordShareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero)
}
self?.transitionToScreen(passwordShareVC, transitionType: .present)
}
}
)
),
transitionType: .present
)
}
if UIDevice.current.isIPad {
shareVC.excludedActivityTypes = []
shareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : [])
shareVC.popoverPresentationController?.sourceView = targetView
shareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero)
}
self?.transitionToScreen(shareVC, transitionType: .present)
}
catch {
let message: String = {
switch error {
case CryptoKitError.incorrectKeySize:
return "The password must be between 6 and 32 characters (padded to 32 bytes)"
default: return "Failed to export database"
}
}()
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "Error",
body: .text(message)
)
),
transitionType: .present
)
}
}
}
)
),
transitionType: .present
)
}
#endif
} }

View File

@ -55,11 +55,9 @@ enum _013_SessionUtilChanges: Migration {
// shares the same 'id' as the 'groupId') so we can cascade delete automatically // shares the same 'id' as the 'groupId') so we can cascade delete automatically
t.column(.groupId, .text) t.column(.groupId, .text)
.notNull() .notNull()
.indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
t.column(.profileId, .text) t.column(.profileId, .text)
.notNull() .notNull()
.indexed() // Quicker querying
t.column(.role, .integer).notNull() t.column(.role, .integer).notNull()
t.column(.isHidden, .boolean) t.column(.isHidden, .boolean)
.notNull() .notNull()
@ -80,6 +78,11 @@ enum _013_SessionUtilChanges: Migration {
try db.drop(table: GroupMember.self) try db.drop(table: GroupMember.self)
try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName) try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName)
// Need to create the indexes separately from creating 'TmpGroupMember' to ensure they
// have the correct names
try db.createIndex(on: GroupMember.self, columns: [.groupId])
try db.createIndex(on: GroupMember.self, columns: [.profileId])
// SQLite doesn't support removing unique constraints so we need to create a new table with // SQLite doesn't support removing unique constraints so we need to create a new table with
// the setup we want, copy data from the old table over, drop the old table and rename the new table // the setup we want, copy data from the old table over, drop the old table and rename the new table
struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible { struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
@ -107,18 +110,16 @@ enum _013_SessionUtilChanges: Migration {
try db.create(table: TmpClosedGroupKeyPair.self) { t in try db.create(table: TmpClosedGroupKeyPair.self) { t in
t.column(.threadId, .text) t.column(.threadId, .text)
.notNull() .notNull()
.indexed() // Quicker querying
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
t.column(.publicKey, .blob).notNull() t.column(.publicKey, .blob).notNull()
t.column(.secretKey, .blob).notNull() t.column(.secretKey, .blob).notNull()
t.column(.receivedTimestamp, .double) t.column(.receivedTimestamp, .double)
.notNull() .notNull()
.indexed() // Quicker querying
t.column(.threadKeyPairHash, .integer) t.column(.threadKeyPairHash, .integer)
.notNull() .notNull()
.unique() .unique()
.indexed()
} }
// Insert into the new table, drop the old table and rename the new table to be the old one // Insert into the new table, drop the old table and rename the new table to be the old one
try ClosedGroupKeyPair try ClosedGroupKeyPair
.fetchAll(db) .fetchAll(db)
@ -144,6 +145,12 @@ enum _013_SessionUtilChanges: Migration {
try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName) try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName)
// Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily // Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily
//
// Note: Need to create the indexes separately from creating 'TmpClosedGroupKeyPair' to ensure they
// have the correct names
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadId])
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.receivedTimestamp])
try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadKeyPairHash])
try db.createIndex( try db.createIndex(
on: ClosedGroupKeyPair.self, on: ClosedGroupKeyPair.self,
columns: [.threadId, .threadKeyPairHash] columns: [.threadId, .threadKeyPairHash]

View File

@ -54,12 +54,13 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis
isApproved: Bool = false, isApproved: Bool = false,
isBlocked: Bool = false, isBlocked: Bool = false,
didApproveMe: Bool = false, didApproveMe: Bool = false,
hasBeenBlocked: Bool = false hasBeenBlocked: Bool = false,
dependencies: Dependencies = Dependencies()
) { ) {
self.id = id self.id = id
self.isTrusted = ( self.isTrusted = (
isTrusted || isTrusted ||
id == getUserHexEncodedPublicKey() // Always trust ourselves id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves
) )
self.isApproved = isApproved self.isApproved = isApproved
self.isBlocked = isBlocked self.isBlocked = isBlocked

View File

@ -36,7 +36,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
case pinnedPriority case pinnedPriority
} }
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable {
case contact case contact
case legacyGroup case legacyGroup
case community case community

View File

@ -34,63 +34,16 @@ internal extension SessionUtil {
mergeNeedsDump: Bool, mergeNeedsDump: Bool,
latestConfigSentTimestampMs: Int64 latestConfigSentTimestampMs: Int64
) throws { ) throws {
typealias ContactData = [
String: (
contact: Contact,
profile: Profile,
priority: Int32,
created: TimeInterval
)
]
guard mergeNeedsDump else { return } guard mergeNeedsDump else { return }
guard conf != nil else { throw SessionUtilError.nilConfigObject } guard conf != nil else { throw SessionUtilError.nilConfigObject }
var contactData: ContactData = [:]
var contact: contacts_contact = contacts_contact()
let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
while !contacts_iterator_done(contactIterator, &contact) {
let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let contactResult: Contact = Contact(
id: contactId,
isApproved: contact.approved,
isBlocked: contact.blocked,
didApproveMe: contact.approved_me
)
let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
let profileResult: Profile = Profile(
id: contactId,
name: String(libSessionVal: contact.name),
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
profilePictureUrl: profilePictureUrl,
profileEncryptionKey: (profilePictureUrl == nil ? nil :
Data(
libSessionVal: contact.profile_pic.key,
count: ProfileManager.avatarAES256KeyByteLength
)
),
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
)
contactData[contactId] = (
contactResult,
profileResult,
contact.priority,
TimeInterval(contact.created)
)
contacts_iterator_advance(contactIterator)
}
contacts_iterator_free(contactIterator) // Need to free the iterator
// The current users contact data is handled separately so exclude it if it's present (as that's // The current users contact data is handled separately so exclude it if it's present (as that's
// actually a bug) // actually a bug)
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
let targetContactData: ContactData = contactData.filter { $0.key != userPublicKey } let targetContactData: [String: ContactData] = extractContacts(
from: conf,
latestConfigSentTimestampMs: latestConfigSentTimestampMs
).filter { $0.key != userPublicKey }
// Since we don't sync 100% of the data stored against the contact and profile objects we // Since we don't sync 100% of the data stored against the contact and profile objects we
// need to only update the data we do have to ensure we don't overwrite anything that doesn't // need to only update the data we do have to ensure we don't overwrite anything that doesn't
@ -490,6 +443,121 @@ internal extension SessionUtil {
return updated return updated
} }
// MARK: - Pruning
static func pruningIfNeeded(
_ db: Database,
conf: UnsafeMutablePointer<config_object>?
) throws {
// First make sure we are actually thowing the correct size constraint error (don't want to prune contacts
// as a result of some other random error
do {
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
return // If we didn't error then no need to prune
}
catch {
guard (error as NSError).userInfo["NSLocalizedDescription"] as? String == "Config data is too large" else {
throw error
}
}
// Extract the contact data from the config
var allContactData: [String: ContactData] = extractContacts(
from: conf,
latestConfigSentTimestampMs: 0
)
// Remove the current user profile info (shouldn't be in there but just in case)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
var cUserPublicKey: [CChar] = userPublicKey.cArray.nullTerminated()
contacts_erase(conf, &cUserPublicKey)
/// Do the following in stages (we want to prune as few contacts as possible because we are essentially deleting data and removing these
/// contacts will result in not just contact data but also associated conversation data for the contact being removed from linked devices
///
///
/// **Step 1** First of all we want to try to detect spam-attacks (ie. if someone creates a bunch of accounts and messages you, and you
/// systematically block every one of those accounts - this can quickly add up)
///
/// We will do this by filtering the contact data to only include blocked contacts, grouping those contacts into contacts created within the
/// same hour and then only including groups that have more than 10 contacts (ie. if you blocked 20 users within an hour we expect those
/// contacts were spammers)
let blockSpamBatchingResolution: TimeInterval = (60 * 60)
// TODO: Do we want to only do this case for contacts that were created over X time ago? (to avoid unintentionally unblocking accounts that were recently blocked
let likelySpammerContacts: [ContactData] = allContactData
.values
.filter { $0.contact.isBlocked }
.grouped(by: { $0.created / blockSpamBatchingResolution })
.filter { _, values in values.count > 20 }
.values
.flatMap { $0 }
if !likelySpammerContacts.isEmpty {
likelySpammerContacts.forEach { contact in
var cSessionId: [CChar] = contact.contact.id.cArray.nullTerminated()
contacts_erase(conf, &cSessionId)
allContactData.removeValue(forKey: contact.contact.id)
}
// If we are no longer erroring then we can stop here
do { return try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch {}
}
/// We retrieve the `CONVO_INFO_VOLATILE` records and one-to-one conversation message counts as they will be relevant for subsequent checks
let volatileThreadInfo: [String: VolatileThreadInfo] = SessionUtil
.config(for: .convoInfoVolatile, publicKey: userPublicKey)
.wrappedValue
.map { SessionUtil.extractConvoVolatileInfo(from: $0) }
.defaulting(to: [])
.reduce(into: [:]) { result, next in result[next.threadId] = next }
let conversationMessageCounts: [String: Int] = try SessionThread
.filter(SessionThread.Columns.variant == SessionThread.Variant.contact)
.select(.id)
.annotated(with: SessionThread.interactions.count)
.asRequest(of: ThreadCount.self)
.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.id] = next.interactionCount }
/// **Step 2** Next up we want to remove contact records which are likely to be invalid, this means contacts which:
/// - Aren't blocked
/// - Have no `name` value
/// - Have no `CONVO_INFO_VOLATILE` record
/// - Have no messages in their one-to-one conversations
///
/// Any contacts that meet the above criteria are likely either invalid contacts or are contacts which the user hasn't seen or interacted
/// with for 30+ days
let likelyInvalidContacts: [ContactData] = allContactData
.values
.filter { !$0.contact.isBlocked }
.filter { $0.profile.name.isEmpty }
.filter { volatileThreadInfo[$0.contact.id] == nil }
.filter { (conversationMessageCounts[$0.contact.id] ?? 0) == 0 }
if !likelyInvalidContacts.isEmpty {
likelyInvalidContacts.forEach { contact in
var cSessionId: [CChar] = contact.contact.id.cArray.nullTerminated()
contacts_erase(conf, &cSessionId)
allContactData.removeValue(forKey: contact.contact.id)
}
// If we are no longer erroring then we can stop here
do { return try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch {}
}
// TODO: Exclude contacts that have no profile info(?)
// TODO: Exclude contacts that have a CONVO_INFO_VOLATILE record
// TODO: Exclude contacts that have a conversation with messages in the database (ie. only delete "empty" contacts)
// TODO: Start pruning valid contacts which have really old conversations...
print("RAWR")
}
} }
// MARK: - External Outgoing Changes // MARK: - External Outgoing Changes
@ -555,3 +623,71 @@ extension SessionUtil {
} }
} }
} }
// MARK: - ContactData
private struct ContactData {
let contact: Contact
let profile: Profile
let priority: Int32
let created: TimeInterval
}
// MARK: - ThreadCount
private struct ThreadCount: Codable, FetchableRecord {
let id: String
let interactionCount: Int
}
// MARK: - Convenience
private extension SessionUtil {
static func extractContacts(
from conf: UnsafeMutablePointer<config_object>?,
latestConfigSentTimestampMs: Int64
) -> [String: ContactData] {
var result: [String: ContactData] = [:]
var contact: contacts_contact = contacts_contact()
let contactIterator: UnsafeMutablePointer<contacts_iterator> = contacts_iterator_new(conf)
while !contacts_iterator_done(contactIterator, &contact) {
let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let contactResult: Contact = Contact(
id: contactId,
isApproved: contact.approved,
isBlocked: contact.blocked,
didApproveMe: contact.approved_me
)
let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true)
let profileResult: Profile = Profile(
id: contactId,
name: String(libSessionVal: contact.name),
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
profilePictureUrl: profilePictureUrl,
profileEncryptionKey: (profilePictureUrl == nil ? nil :
Data(
libSessionVal: contact.profile_pic.key,
count: ProfileManager.avatarAES256KeyByteLength
)
),
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
)
result[contactId] = ContactData(
contact: contactResult,
profile: profileResult,
priority: contact.priority,
created: TimeInterval(contact.created)
)
contacts_iterator_advance(contactIterator)
}
contacts_iterator_free(contactIterator) // Need to free the iterator
return result
}
}

View File

@ -22,71 +22,8 @@ internal extension SessionUtil {
guard mergeNeedsDump else { return } guard mergeNeedsDump else { return }
guard conf != nil else { throw SessionUtilError.nilConfigObject } guard conf != nil else { throw SessionUtilError.nilConfigObject }
var volatileThreadInfo: [VolatileThreadInfo] = [] // Get the volatile thread info from the conf and local conversations
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() let volatileThreadInfo: [VolatileThreadInfo] = extractConvoVolatileInfo(from: conf)
var community: convo_info_volatile_community = convo_info_volatile_community()
var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group()
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
while !convo_info_volatile_iterator_done(convoIterator) {
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: String(libSessionVal: oneToOne.session_id),
variant: .contact,
changes: [
.markedAsUnread(oneToOne.unread),
.lastReadTimestampMs(oneToOne.last_read)
]
)
)
}
else if convo_info_volatile_it_is_community(convoIterator, &community) {
let server: String = String(libSessionVal: community.base_url)
let roomToken: String = String(libSessionVal: community.room)
let publicKey: String = Data(
libSessionVal: community.pubkey,
count: OpenGroup.pubkeyByteLength
).toHexString()
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
variant: .community,
openGroupUrlInfo: OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: publicKey
),
changes: [
.markedAsUnread(community.unread),
.lastReadTimestampMs(community.last_read)
]
)
)
}
else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) {
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: String(libSessionVal: legacyGroup.group_id),
variant: .legacyGroup,
changes: [
.markedAsUnread(legacyGroup.unread),
.lastReadTimestampMs(legacyGroup.last_read)
]
)
)
}
else {
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
}
convo_info_volatile_iterator_advance(convoIterator)
}
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
// Get the local volatile thread info from all conversations
let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db) let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.threadId] = next } .reduce(into: [:]) { result, next in result[next.threadId] = next }
@ -572,6 +509,76 @@ public extension SessionUtil {
} }
} }
} }
internal static func extractConvoVolatileInfo(
from conf: UnsafeMutablePointer<config_object>?
) -> [VolatileThreadInfo] {
var result: [VolatileThreadInfo] = []
var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1()
var community: convo_info_volatile_community = convo_info_volatile_community()
var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group()
let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf)
while !convo_info_volatile_iterator_done(convoIterator) {
if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) {
result.append(
VolatileThreadInfo(
threadId: String(libSessionVal: oneToOne.session_id),
variant: .contact,
changes: [
.markedAsUnread(oneToOne.unread),
.lastReadTimestampMs(oneToOne.last_read)
]
)
)
}
else if convo_info_volatile_it_is_community(convoIterator, &community) {
let server: String = String(libSessionVal: community.base_url)
let roomToken: String = String(libSessionVal: community.room)
let publicKey: String = Data(
libSessionVal: community.pubkey,
count: OpenGroup.pubkeyByteLength
).toHexString()
result.append(
VolatileThreadInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
variant: .community,
openGroupUrlInfo: OpenGroupUrlInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
server: server,
roomToken: roomToken,
publicKey: publicKey
),
changes: [
.markedAsUnread(community.unread),
.lastReadTimestampMs(community.last_read)
]
)
)
}
else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) {
result.append(
VolatileThreadInfo(
threadId: String(libSessionVal: legacyGroup.group_id),
variant: .legacyGroup,
changes: [
.markedAsUnread(legacyGroup.unread),
.lastReadTimestampMs(legacyGroup.last_read)
]
)
)
}
else {
SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update")
}
convo_info_volatile_iterator_advance(convoIterator)
}
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
return result
}
} }
fileprivate extension [SessionUtil.VolatileThreadInfo.Change] { fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
@ -597,3 +604,4 @@ fileprivate extension [SessionUtil.VolatileThreadInfo.Change] {
return nil return nil
} }
} }

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation import Foundation
import GRDB
import Sodium import Sodium
import SessionUtil import SessionUtil
import SessionUtilitiesKit import SessionUtilitiesKit
@ -8,12 +9,248 @@ import SessionUtilitiesKit
import Quick import Quick
import Nimble import Nimble
@testable import SessionMessagingKit
/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches /// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches
class ConfigContactsSpec { class ConfigContactsSpec {
enum ContactProperty: CaseIterable {
case name
case nickname
case approved
case approved_me
case blocked
case profile_pic
case created
case notifications
case mute_until
}
// MARK: - Spec // MARK: - Spec
static func spec() { static func spec() {
it("generates Contact configs correctly") { context("CONTACTS") {
// MARK: - when checking error catching
context("when checking error catching") {
var seed: Data!
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
var edSK: [UInt8]!
var error: UnsafeMutablePointer<CChar>?
var conf: UnsafeMutablePointer<config_object>?
beforeEach {
seed = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
identity = try! Identity.generate(from: seed)
edSK = identity.ed25519KeyPair.secretKey
// Initialize a brand new, empty config because we have no dump data to deal with.
error = nil
conf = nil
_ = contacts_init(&conf, &edSK, nil, 0, error)
error?.deallocate()
}
// MARK: -- it can catch size limit errors thrown when pushing
it("can catch size limit errors thrown when pushing") {
try (0..<10000).forEach { index in
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
contacts_set(conf, &contact)
}
expect(contacts_size(conf)).to(equal(10000))
expect(config_needs_push(conf)).to(beTrue())
expect(config_needs_dump(conf)).to(beTrue())
expect {
try CExceptionHelper.performSafely { config_push(conf).deallocate() }
}
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
}
// MARK: -- can catch size limit errors thrown when dumping
it("can catch size limit errors thrown when dumping") {
try (0..<10000).forEach { index in
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
contacts_set(conf, &contact)
}
expect(contacts_size(conf)).to(equal(10000))
expect(config_needs_push(conf)).to(beTrue())
expect(config_needs_dump(conf)).to(beTrue())
expect {
try CExceptionHelper.performSafely {
var dump: UnsafeMutablePointer<UInt8>? = nil
var dumpLen: Int = 0
config_dump(conf, &dump, &dumpLen)
dump?.deallocate()
}
}
.to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"])))
}
}
// MARK: - when checking size limits
context("when checking size limits") {
var numRecords: Int!
var seed: Data!
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
var edSK: [UInt8]!
var error: UnsafeMutablePointer<CChar>?
var conf: UnsafeMutablePointer<config_object>?
beforeEach {
numRecords = 0
seed = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
identity = try! Identity.generate(from: seed)
edSK = identity.ed25519KeyPair.secretKey
// Initialize a brand new, empty config because we have no dump data to deal with.
error = nil
conf = nil
_ = contacts_init(&conf, &edSK, nil, 0, error)
error?.deallocate()
}
// MARK: -- has not changed the max empty records
it("has not changed the max empty records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(1775))
}
// MARK: -- has not changed the max name only records
it("has not changed the max name only records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: [.name])
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(526))
}
// MARK: -- has not changed the max name and profile pic only records
it("has not changed the max name and profile pic only records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: [.name, .profile_pic])
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(184))
}
// MARK: -- has not changed the max filled records
it("has not changed the max filled records") {
for index in (0..<10000) {
var contact: contacts_contact = try createContact(for: index, in: conf, maxing: .allProperties)
contacts_set(conf, &contact)
do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } }
catch { break }
// We successfully inserted a contact and didn't hit the limit so increment the counter
numRecords += 1
}
// Check that the record count matches the maximum when we last checked
expect(numRecords).to(equal(134))
}
}
// MARK: - when pruning
context("when pruning") {
var mockStorage: Storage!
var seed: Data!
var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)!
var edSK: [UInt8]!
var error: UnsafeMutablePointer<CChar>?
var conf: UnsafeMutablePointer<config_object>?
beforeEach {
mockStorage = Storage(
customWriter: try! DatabaseQueue(),
customMigrations: [
SNUtilitiesKit.migrations(),
SNMessagingKit.migrations()
]
)
seed = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
identity = try! Identity.generate(from: seed)
edSK = identity.ed25519KeyPair.secretKey
// Initialize a brand new, empty config because we have no dump data to deal with.
error = nil
conf = nil
_ = contacts_init(&conf, &edSK, nil, 0, error)
error?.deallocate()
}
it("does something") {
mockStorage.write { db in
try SessionThread.fetchOrCreate(db, id: "1", variant: .contact, shouldBeVisible: true)
try SessionThread.fetchOrCreate(db, id: "2", variant: .contact, shouldBeVisible: true)
try SessionThread.fetchOrCreate(db, id: "3", variant: .contact, shouldBeVisible: true)
_ = try Interaction(
threadId: "1",
authorId: "1",
variant: .standardIncoming,
body: "Test1"
).inserted(db)
_ = try Interaction(
threadId: "1",
authorId: "2",
variant: .standardIncoming,
body: "Test2"
).inserted(db)
_ = try Interaction(
threadId: "3",
authorId: "3",
variant: .standardIncoming,
body: "Test3"
).inserted(db)
try SessionUtil.pruningIfNeeded(
db,
conf: conf
)
expect(contacts_size(conf)).to(equal(0))
}
}
}
// MARK: - generates config correctly
it("generates config correctly") {
let createdTs: Int64 = 1680064059 let createdTs: Int64 = 1680064059
let nowTs: Int64 = Int64(Date().timeIntervalSince1970) let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
@ -304,3 +541,69 @@ class ConfigContactsSpec {
} }
} }
} }
// MARK: - Convenience
private static func createContact(
for index: Int,
in conf: UnsafeMutablePointer<config_object>?,
maxing properties: [ContactProperty] = []
) throws -> contacts_contact {
let postPrefixId: String = "050000000000000000000000000000000000000000000000000000000000000000"
let sessionId: String = ("05\(index)a" + postPrefixId.suffix(postPrefixId.count - "05\(index)a".count))
var cSessionId: [CChar] = sessionId.cArray.nullTerminated()
var contact: contacts_contact = contacts_contact()
guard contacts_get_or_construct(conf, &contact, &cSessionId) else {
throw SessionUtilError.getOrConstructFailedUnexpectedly
}
// Set the values to the maximum data that can fit
properties.forEach { property in
switch property {
case .approved: contact.approved = true
case .approved_me: contact.approved_me = true
case .blocked: contact.blocked = true
case .created: contact.created = Int64.max
case .notifications: contact.notifications = CONVO_NOTIFY_MENTIONS_ONLY
case .mute_until: contact.mute_until = Int64.max
case .name:
contact.name = String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxNameByteLength
),
encoding: .utf8
).toLibSession()
case .nickname:
contact.nickname = String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxNameByteLength
),
encoding: .utf8
).toLibSession()
case .profile_pic:
contact.profile_pic = user_profile_pic(
url: String(
data: Data(
repeating: "A".data(using: .utf8)![0],
count: SessionUtil.libSessionMaxProfileUrlByteLength
),
encoding: .utf8
).toLibSession(),
key: "qwerty78901234567890123456789012".data(using: .utf8)!.toLibSession()
)
}
}
return contact
}
}
fileprivate extension Array where Element == ConfigContactsSpec.ContactProperty {
static var allProperties: [ConfigContactsSpec.ContactProperty] = ConfigContactsSpec.ContactProperty.allCases
}

View File

@ -13,7 +13,8 @@ class ConfigConvoInfoVolatileSpec {
// MARK: - Spec // MARK: - Spec
static func spec() { static func spec() {
it("generates ConvoInfoVolatile configs correctly") { context("CONVO_INFO_VOLATILE") {
it("generates config correctly") {
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
@ -263,3 +264,4 @@ class ConfigConvoInfoVolatileSpec {
} }
} }
} }
}

View File

@ -83,7 +83,8 @@ class ConfigUserGroupsSpec {
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
} }
it("generates UserGroup configs correctly") { context("USER_GROUPS") {
it("generates config correctly") {
let createdTs: Int64 = 1680064059 let createdTs: Int64 = 1680064059
let nowTs: Int64 = Int64(Date().timeIntervalSince1970) let nowTs: Int64 = Int64(Date().timeIntervalSince1970)
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
@ -585,3 +586,4 @@ class ConfigUserGroupsSpec {
} }
} }
} }
}

View File

@ -14,7 +14,8 @@ class ConfigUserProfileSpec {
// MARK: - Spec // MARK: - Spec
static func spec() { static func spec() {
it("generates UserProfile configs correctly") { context("USER_PROFILE") {
it("generates config correctly") {
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
@ -395,3 +396,4 @@ class ConfigUserProfileSpec {
} }
} }
} }
}

View File

@ -12,9 +12,11 @@ class LibSessionSpec: QuickSpec {
// MARK: - Spec // MARK: - Spec
override func spec() { override func spec() {
describe("libSession") {
ConfigContactsSpec.spec() ConfigContactsSpec.spec()
ConfigUserProfileSpec.spec() ConfigUserProfileSpec.spec()
ConfigConvoInfoVolatileSpec.spec() ConfigConvoInfoVolatileSpec.spec()
ConfigUserGroupsSpec.spec() ConfigUserGroupsSpec.spec()
} }
} }
}

View File

@ -53,12 +53,16 @@ class ThreadSettingsViewModelSpec: QuickSpec {
try Profile( try Profile(
id: "05\(TestConstants.publicKey)", id: "05\(TestConstants.publicKey)",
name: "TestMe" name: "TestMe",
lastNameUpdate: 0,
lastProfilePictureUpdate: 0
).insert(db) ).insert(db)
try Profile( try Profile(
id: "TestId", id: "TestId",
name: "TestUser" name: "TestUser",
lastNameUpdate: 0,
lastProfilePictureUpdate: 0
).insert(db) ).insert(db)
} }
viewModel = ThreadSettingsViewModel( viewModel = ThreadSettingsViewModel(

View File

@ -4,15 +4,38 @@ import UIKit
import SessionUtilitiesKit import SessionUtilitiesKit
// FIXME: Refactor as part of the Groups Rebuild // FIXME: Refactor as part of the Groups Rebuild
public class ConfirmationModal: Modal { public class ConfirmationModal: Modal, UITextFieldDelegate {
private static let closeSize: CGFloat = 24 private static let closeSize: CGFloat = 24
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
private var internalOnCancel: ((ConfirmationModal) -> ())? = nil private var internalOnCancel: ((ConfirmationModal) -> ())? = nil
private var internalOnBodyTap: (() -> ())? = nil private var internalOnBodyTap: (() -> ())? = nil
private var internalOnTextChanged: ((String) -> ())? = nil
// MARK: - Components // MARK: - Components
private lazy var contentTapGestureRecognizer: UITapGestureRecognizer = {
let result: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(contentViewTapped)
)
contentView.addGestureRecognizer(result)
result.isEnabled = false
return result
}()
private lazy var imageViewTapGestureRecognizer: UITapGestureRecognizer = {
let result: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(imageViewTapped)
)
imageViewContainer.addGestureRecognizer(result)
result.isEnabled = false
return result
}()
private lazy var titleLabel: UILabel = { private lazy var titleLabel: UILabel = {
let result: UILabel = UILabel() let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
@ -36,16 +59,30 @@ public class ConfirmationModal: Modal {
return result return result
}() }()
private lazy var textFieldContainer: UIView = {
let result: UIView = UIView()
result.themeBorderColor = .borderSeparator
result.layer.cornerRadius = 11
result.layer.borderWidth = 1
result.isHidden = true
result.set(.height, to: 40)
return result
}()
private lazy var textField: UITextField = {
let result: UITextField = UITextField()
result.font = .systemFont(ofSize: Values.smallFontSize)
result.themeTextColor = .textPrimary
result.delegate = self
return result
}()
private lazy var imageViewContainer: UIView = { private lazy var imageViewContainer: UIView = {
let result: UIView = UIView() let result: UIView = UIView()
result.isHidden = true result.isHidden = true
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(imageViewTapped)
)
result.addGestureRecognizer(gestureRecogniser)
return result return result
}() }()
@ -70,7 +107,7 @@ public class ConfirmationModal: Modal {
}() }()
private lazy var contentStackView: UIStackView = { private lazy var contentStackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, imageViewContainer ]) let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, textFieldContainer, imageViewContainer ])
result.axis = .vertical result.axis = .vertical
result.spacing = Values.smallSpacing result.spacing = Values.smallSpacing
result.isLayoutMarginsRelativeArrangement = true result.isLayoutMarginsRelativeArrangement = true
@ -132,13 +169,22 @@ public class ConfirmationModal: Modal {
} }
public override func populateContentView() { public override func populateContentView() {
let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(contentViewTapped)
)
contentView.addGestureRecognizer(gestureRecogniser)
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
contentView.addSubview(closeButton) contentView.addSubview(closeButton)
textFieldContainer.addSubview(textField)
textField.pin(to: textFieldContainer, withInset: 12)
imageViewContainer.addSubview(profileView) imageViewContainer.addSubview(profileView)
profileView.center(.horizontal, in: imageViewContainer) profileView.center(.horizontal, in: imageViewContainer)
profileView.pin(.top, to: .top, of: imageViewContainer)//, withInset: 15) profileView.pin(.top, to: .top, of: imageViewContainer)
profileView.pin(.bottom, to: .bottom, of: imageViewContainer)//, withInset: -15) profileView.pin(.bottom, to: .bottom, of: imageViewContainer)
mainStackView.pin(to: contentView) mainStackView.pin(to: contentView)
closeButton.pin(.top, to: .top, of: contentView, withInset: 8) closeButton.pin(.top, to: .top, of: contentView, withInset: 8)
@ -149,6 +195,7 @@ public class ConfirmationModal: Modal {
public func updateContent(with info: Info) { public func updateContent(with info: Info) {
internalOnBodyTap = nil internalOnBodyTap = nil
internalOnTextChanged = nil
internalOnConfirm = { modal in internalOnConfirm = { modal in
if info.dismissOnConfirm { if info.dismissOnConfirm {
modal.close() modal.close()
@ -161,6 +208,8 @@ public class ConfirmationModal: Modal {
info.onCancel?(modal) info.onCancel?(modal)
} }
contentTapGestureRecognizer.isEnabled = true
imageViewTapGestureRecognizer.isEnabled = false
// Set the content based on the provided info // Set the content based on the provided info
titleLabel.text = info.title titleLabel.text = info.title
@ -179,6 +228,15 @@ public class ConfirmationModal: Modal {
explanationLabel.attributedText = attributedText explanationLabel.attributedText = attributedText
explanationLabel.isHidden = false explanationLabel.isHidden = false
case .input(let explanation, let placeholder, let value, let clearButton, let onTextChanged):
explanationLabel.attributedText = explanation
explanationLabel.isHidden = (explanation == nil)
textField.placeholder = placeholder
textField.text = (value ?? "")
textField.clearButtonMode = (clearButton ? .always : .never)
textFieldContainer.isHidden = false
internalOnTextChanged = onTextChanged
case .image(let placeholder, let value, let icon, let style, let accessibility, let onClick): case .image(let placeholder, let value, let icon, let style, let accessibility, let onClick):
imageViewContainer.isAccessibilityElement = (accessibility != nil) imageViewContainer.isAccessibilityElement = (accessibility != nil)
imageViewContainer.accessibilityIdentifier = accessibility?.identifier imageViewContainer.accessibilityIdentifier = accessibility?.identifier
@ -193,6 +251,8 @@ public class ConfirmationModal: Modal {
) )
) )
internalOnBodyTap = onClick internalOnBodyTap = onClick
contentTapGestureRecognizer.isEnabled = false
imageViewTapGestureRecognizer.isEnabled = true
} }
confirmButton.accessibilityLabel = info.confirmAccessibility?.label confirmButton.accessibilityLabel = info.confirmAccessibility?.label
@ -216,8 +276,38 @@ public class ConfirmationModal: Modal {
contentView.accessibilityIdentifier = info.accessibility?.identifier contentView.accessibilityIdentifier = info.accessibility?.identifier
} }
// MARK: - UITextFieldDelegate
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
public func textFieldShouldClear(_ textField: UITextField) -> Bool {
internalOnTextChanged?("")
return true
}
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let text: String = textField.text, let textRange: Range = Range(range, in: text) {
let updatedText = text.replacingCharacters(in: textRange, with: string)
internalOnTextChanged?(updatedText)
}
return true
}
// MARK: - Interaction // MARK: - Interaction
@objc private func contentViewTapped() {
if textField.isFirstResponder {
textField.resignFirstResponder()
}
internalOnBodyTap?()
}
@objc private func imageViewTapped() { @objc private func imageViewTapped() {
internalOnBodyTap?() internalOnBodyTap?()
} }
@ -400,8 +490,14 @@ public extension ConfirmationModal.Info {
case none case none
case text(String) case text(String)
case attributedText(NSAttributedString) case attributedText(NSAttributedString)
// FIXME: Implement these case input(
// case input(placeholder: String, value: String?) explanation: NSAttributedString?,
placeholder: String,
initialValue: String?,
clearButton: Bool,
onChange: (String) -> ()
)
// FIXME: Implement this
// case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)]) // case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)])
case image( case image(
placeholderData: Data?, placeholderData: Data?,
@ -418,14 +514,15 @@ public extension ConfirmationModal.Info {
case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText) case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText)
case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText) case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText)
// FIXME: Implement these case (.input(let lhsExplanation, let lhsPlaceholder, let lhsInitialValue, let lhsClearButton, _), .input(let rhsExplanation, let rhsPlaceholder, let rhsInitialValue, let rhsClearButton, _)):
//case (.input(let lhsPlaceholder, let lhsValue), .input(let rhsPlaceholder, let rhsValue)): return (
// return ( lhsExplanation == rhsExplanation &&
// lhsPlaceholder == rhsPlaceholder && lhsPlaceholder == rhsPlaceholder &&
// lhsValue == rhsValue && lhsInitialValue == rhsInitialValue &&
// ) lhsClearButton == rhsClearButton
)
// FIXME: Implement these // FIXME: Implement this
//case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)): //case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)):
// return ( // return (
// lhsExplanation == rhsExplanation && // lhsExplanation == rhsExplanation &&
@ -451,6 +548,12 @@ public extension ConfirmationModal.Info {
case .text(let text): text.hash(into: &hasher) case .text(let text): text.hash(into: &hasher)
case .attributedText(let text): text.hash(into: &hasher) case .attributedText(let text): text.hash(into: &hasher)
case .input(let explanation, let placeholder, let initialValue, let clearButton, _):
explanation.hash(into: &hasher)
placeholder.hash(into: &hasher)
initialValue.hash(into: &hasher)
clearButton.hash(into: &hasher)
case .image(let placeholder, let value, let icon, let style, let accessibility, _): case .image(let placeholder, let value, let icon, let style, let accessibility, _):
placeholder.hash(into: &hasher) placeholder.hash(into: &hasher)
value.hash(into: &hasher) value.hash(into: &hasher)

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation import Foundation
import CryptoKit
import Combine import Combine
import GRDB import GRDB
import SignalCoreKit import SignalCoreKit
@ -492,3 +493,38 @@ public extension ValueObservation {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
// MARK: - Debug Convenience
#if DEBUG
public extension Storage {
func exportInfo(password: String) throws -> (dbPath: String, keyPath: String) {
var keySpec: Data = try Storage.getOrGenerateDatabaseKeySpec()
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
guard var passwordData: Data = password.data(using: .utf8) else { throw StorageError.generic }
defer { passwordData.resetBytes(in: 0..<passwordData.count) } // Reset content immediately after use
/// Encrypt the `keySpec` value using a SHA256 of the password provided and a nonce then base64-encode the encrypted
/// data and save it to a temporary file to share alongside the database
///
/// Decrypt the key via the termincal on macOS by running the command in the project root directory
/// `swift ./Scropts/DecryptExportedKey.swift {BASE64_CIPHERTEXT} {PASSWORD}`
///
/// Where `BASE64_CIPHERTEXT` is the content of the `key.enc` file and `PASSWORD` is the password provided via the
/// prompt during export
let nonce: ChaChaPoly.Nonce = ChaChaPoly.Nonce()
let hash: SHA256.Digest = SHA256.hash(data: passwordData)
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
let sealedBox: ChaChaPoly.SealedBox = try ChaChaPoly.seal(keySpec, using: key, nonce: nonce, authenticating: Data())
let keyInfoPath: String = "\(NSTemporaryDirectory())key.enc"
let encryptedKeyBase64: String = sealedBox.combined.base64EncodedString()
try encryptedKeyBase64.write(toFile: keyInfoPath, atomically: true, encoding: .utf8)
return (
Storage.databasePath,
keyInfoPath
)
}
}
#endif

View File

@ -16,4 +16,5 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[];
#import <SessionUtilitiesKit/UIImage+OWS.h> #import <SessionUtilitiesKit/UIImage+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h> #import <SessionUtilitiesKit/UIView+OWS.h>
#import <SessionUtilitiesKit/OWSBackgroundTask.h> #import <SessionUtilitiesKit/OWSBackgroundTask.h>
#import <SessionUtilitiesKit/CExceptionHelper.h>

View File

@ -0,0 +1,16 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
#ifndef __CExceptionHelper_h__
#define __CExceptionHelper_h__
#import <Foundation/Foundation.h>
#define noEscape __attribute__((noescape))
@interface CExceptionHelper: NSObject
+ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
@end
#endif

View File

@ -0,0 +1,36 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// This logic is not foolproof and may result in memory-leaks, when possible we should look to remove this
// and use the native C++ <-> Swift interoperability coming with Swift 5.9
//
// This solution was sourced from the following link, for more information please refer to this thread:
// https://forums.swift.org/t/pitch-a-swift-representation-for-thrown-and-caught-exceptions/54583
#import "CExceptionHelper.h"
#include <exception>
@implementation CExceptionHelper
+ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
try {
tryBlock();
return YES;
}
catch(NSException* e) {
*error = [[NSError alloc] initWithDomain:e.name code:-1 userInfo:e.userInfo];
return NO;
}
catch (std::exception& e) {
NSString* what = [NSString stringWithUTF8String: e.what()];
NSDictionary* userInfo = @{NSLocalizedDescriptionKey : what};
*error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-2 userInfo:userInfo];
return NO;
}
catch(...) {
NSDictionary* userInfo = @{NSLocalizedDescriptionKey:@"Other C++ exception"};
*error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-3 userInfo:userInfo];
return NO;
}
}
@end