You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKit/SessionUtil/SessionUtil.swift

667 lines
29 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionSnodeKit
import SessionUtil
import SessionUtilitiesKit
// MARK: - SessionUtil
public enum SessionUtil {
internal static let logLevel: config_log_level = LOG_LEVEL_INFO
// MARK: - Variables
internal static func syncDedupeId(_ sessionIdHexString: String) -> String {
return "EnqueueConfigurationSyncJob-\(sessionIdHexString)" // stringlint:disable
}
public static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) }
// MARK: - Loading
public static func clearMemoryState(using dependencies: Dependencies) {
dependencies.mutate(cache: .sessionUtil) { cache in
cache.removeAll()
}
}
public static func loadState(_ db: Database, using dependencies: Dependencies) {
// Ensure we have the ed25519 key and that we haven't already loaded the state before
// we continue
guard
let ed25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db, using: dependencies),
dependencies[cache: .sessionUtil].isEmpty
else { return SNLog("[SessionUtil] Ignoring loadState due to existing state") }
// Retrieve the existing dumps from the database
let userSessionId: SessionId = getUserSessionId(db, using: dependencies)
let existingDumps: [ConfigDump] = ((try? ConfigDump.fetchSet(db)) ?? [])
.sorted { lhs, rhs in lhs.variant.loadOrder < rhs.variant.loadOrder }
let existingDumpVariants: Set<ConfigDump.Variant> = existingDumps
.map { $0.variant }
.asSet()
let missingRequiredVariants: Set<ConfigDump.Variant> = ConfigDump.Variant.userVariants
.subtracting(existingDumpVariants)
let groupsByKey: [String: ClosedGroup] = (try? ClosedGroup
.filter(ClosedGroup.Columns.threadId.like("\(SessionId.Prefix.group.rawValue)%"))
.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.threadId] = next })
.defaulting(to: [:])
let groupsWithNoDumps: [ClosedGroup] = groupsByKey
.values
.filter { group in !existingDumps.contains(where: { $0.sessionId.hexString == group.id }) }
// Create the config records for each dump
dependencies.mutate(cache: .sessionUtil) { cache in
existingDumps.forEach { dump in
cache.setConfig(
for: dump.variant,
sessionId: dump.sessionId,
to: try? SessionUtil
.loadState(
for: dump.variant,
sessionId: dump.sessionId,
userEd25519SecretKey: ed25519KeyPair.secretKey,
groupEd25519SecretKey: groupsByKey[dump.sessionId.hexString]?
.groupIdentityPrivateKey
.map { Array($0) },
cachedData: dump.data,
cache: cache
)
.addingLogger()
)
}
/// It's possible for there to not be dumps for all of the user configs so we load any missing ones to ensure funcitonality
/// works smoothly
missingRequiredVariants.forEach { variant in
cache.setConfig(
for: variant,
sessionId: userSessionId,
to: try? SessionUtil
.loadState(
for: variant,
sessionId: userSessionId,
userEd25519SecretKey: ed25519KeyPair.secretKey,
groupEd25519SecretKey: nil,
cachedData: nil,
cache: cache
)
.addingLogger()
)
}
}
/// It's possible for a group to get created but for a dump to not be created (eg. when a crash happens at the right time), to
/// handle this we also load the state of any groups which don't have dumps if they aren't in the `invited` state (those in
/// the `invited` state will have their state loaded if the invite is accepted)
groupsWithNoDumps
.filter { $0.invited != true }
.forEach { group in
_ = try? SessionUtil.createGroupState(
groupSessionId: SessionId(.group, hex: group.id),
userED25519KeyPair: ed25519KeyPair,
groupIdentityPrivateKey: group.groupIdentityPrivateKey,
shouldLoadState: true,
using: dependencies
)
}
SNLog("[SessionUtil] Completed loadState")
}
private static func loadState(
for variant: ConfigDump.Variant,
sessionId: SessionId,
userEd25519SecretKey: [UInt8],
groupEd25519SecretKey: [UInt8]?,
cachedData: Data?,
cache: SessionUtilCacheType
) throws -> Config {
// Setup initial variables (including getting the memory address for any cached data)
var conf: UnsafeMutablePointer<config_object>? = nil
var keysConf: UnsafeMutablePointer<config_group_keys>? = nil
var secretKey: [UInt8] = userEd25519SecretKey
var error: [CChar] = [CChar](repeating: 0, count: 256)
let cachedDump: (data: UnsafePointer<UInt8>, length: Int)? = cachedData?.withUnsafeBytes { unsafeBytes in
return unsafeBytes.baseAddress.map {
(
$0.assumingMemoryBound(to: UInt8.self),
unsafeBytes.count
)
}
}
let userConfigInitCalls: [ConfigDump.Variant: UserConfigInitialiser] = [
.userProfile: user_profile_init,
.contacts: contacts_init,
.convoInfoVolatile: convo_info_volatile_init,
.userGroups: user_groups_init
]
let groupConfigInitCalls: [ConfigDump.Variant: GroupConfigInitialiser] = [
.groupInfo: groups_info_init,
.groupMembers: groups_members_init
]
switch (variant, groupEd25519SecretKey) {
case (.invalid, _):
SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object")
throw SessionUtilError.unableToCreateConfigObject
case (.userProfile, _), (.contacts, _), (.convoInfoVolatile, _), (.userGroups, _):
return try (userConfigInitCalls[variant]?(
&conf,
&secretKey,
cachedDump?.data,
(cachedDump?.length ?? 0),
&error
))
.toConfig(conf, variant: variant, error: error)
case (.groupInfo, .some(var adminSecretKey)), (.groupMembers, .some(var adminSecretKey)):
var identityPublicKey: [UInt8] = sessionId.publicKey
return try (groupConfigInitCalls[variant]?(
&conf,
&identityPublicKey,
&adminSecretKey,
cachedDump?.data,
(cachedDump?.length ?? 0),
&error
))
.toConfig(conf, variant: variant, error: error)
case (.groupKeys, .some(var adminSecretKey)):
var identityPublicKey: [UInt8] = sessionId.publicKey
let infoConfig: Config? = cache.config(for: .groupInfo, sessionId: sessionId).wrappedValue
let membersConfig: Config? = cache.config(for: .groupMembers, sessionId: sessionId).wrappedValue
guard
case .object(let infoConf) = infoConfig,
case .object(let membersConf) = membersConfig
else {
SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object: Group info and member config states not loaded")
throw SessionUtilError.unableToCreateConfigObject
}
return try groups_keys_init(
&keysConf,
&secretKey,
&identityPublicKey,
&adminSecretKey,
infoConf,
membersConf,
cachedDump?.data,
(cachedDump?.length ?? 0),
&error
)
.toConfig(keysConf, info: infoConf, members: membersConf, variant: variant, error: error)
// It looks like C doesn't deal will passing pointers to null variables well so we need
// to explicitly pass 'nil' for the admin key in this case
case (.groupInfo, .none), (.groupMembers, .none):
var identityPublicKey: [UInt8] = sessionId.publicKey
return try (groupConfigInitCalls[variant]?(
&conf,
&identityPublicKey,
nil,
cachedDump?.data,
(cachedDump?.length ?? 0),
&error
))
.toConfig(conf, variant: variant, error: error)
// It looks like C doesn't deal will passing pointers to null variables well so we need
// to explicitly pass 'nil' for the admin key in this case
case (.groupKeys, .none):
var identityPublicKey: [UInt8] = sessionId.publicKey
let infoConfig: Config? = cache.config(for: .groupInfo, sessionId: sessionId).wrappedValue
let membersConfig: Config? = cache.config(for: .groupMembers, sessionId: sessionId).wrappedValue
guard
case .object(let infoConf) = infoConfig,
case .object(let membersConf) = membersConfig
else {
SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object: Group info and member config states not loaded")
throw SessionUtilError.unableToCreateConfigObject
}
return try groups_keys_init(
&keysConf,
&secretKey,
&identityPublicKey,
nil,
infoConf,
membersConf,
cachedDump?.data,
(cachedDump?.length ?? 0),
&error
)
.toConfig(keysConf, info: infoConf, members: membersConf, variant: variant, error: error)
}
}
internal static func createDump(
config: Config?,
for variant: ConfigDump.Variant,
sessionId: SessionId,
timestampMs: Int64,
using dependencies: Dependencies
) throws -> ConfigDump? {
// If it doesn't need a dump then do nothing
guard
config.needsDump(using: dependencies),
let dumpData: Data = try config?.dump()
else { return nil }
return ConfigDump(
variant: variant,
sessionId: sessionId.hexString,
data: dumpData,
timestampMs: timestampMs
)
}
// MARK: - Pushes
public static func pendingChanges(
_ db: Database,
sessionIdHexString: String,
using dependencies: Dependencies
) throws -> [PushData] {
guard Identity.userExists(db, using: dependencies) else { throw SessionUtilError.userDoesNotExist }
// Get a list of the different config variants for the provided publicKey
let userSessionId: SessionId = getUserSessionId(db, using: dependencies)
let targetVariants: [(sessionId: SessionId, variant: ConfigDump.Variant)] = {
switch (sessionIdHexString, try? SessionId(from: sessionIdHexString)) {
case (userSessionId.hexString, _):
return ConfigDump.Variant.userVariants.map { (userSessionId, $0) }
case (_, .some(let sessionId)) where sessionId.prefix == .group:
return ConfigDump.Variant.groupVariants.map { (sessionId, $0) }
default: return []
}
}()
// Extract any pending changes from the cached config entry for each variant
return try targetVariants
.sorted { (lhs: (SessionId, ConfigDump.Variant), rhs: (SessionId, ConfigDump.Variant)) in
lhs.1.sendOrder < rhs.1.sendOrder
}
.compactMap { sessionId, variant -> PushData? in
try dependencies[cache: .sessionUtil]
.config(for: variant, sessionId: sessionId)
.wrappedValue
.map { config -> PushData? in
// Check if the config needs to be pushed
guard config.needsPush else { return nil }
return try Result(catching: { try config.push(variant: variant) })
.onFailure { error in
let configCountInfo: String = config.count(for: variant)
SNLog("[SessionUtil] Failed to generate push data for \(variant) config data, size: \(configCountInfo), error: \(error)")
}
.successOrThrow()
}
}
}
public static func markingAsPushed(
seqNo: Int64,
serverHash: String,
sentTimestamp: Int64,
variant: ConfigDump.Variant,
sessionIdHexString: String,
using dependencies: Dependencies
) -> ConfigDump? {
let sessionId: SessionId = SessionId(hex: sessionIdHexString, dumpVariant: variant)
return dependencies[cache: .sessionUtil]
.config(for: variant, sessionId: sessionId)
.mutate { config -> ConfigDump? in
guard config != nil else { return nil }
// Mark the config as pushed
config?.confirmPushed(seqNo: seqNo, hash: serverHash)
// Update the result to indicate whether the config needs to be dumped
guard config.needsPush else { return nil }
return try? SessionUtil.createDump(
config: config,
for: variant,
sessionId: sessionId,
timestampMs: sentTimestamp,
using: dependencies
)
}
}
public static func configHashes(
for sessionIdHexString: String,
using dependencies: Dependencies
) -> [String] {
return dependencies[singleton: .storage]
.read { db -> Set<ConfigDump.Variant> in
guard Identity.userExists(db) else { return [] }
return try ConfigDump
.select(.variant)
.filter(ConfigDump.Columns.publicKey == sessionIdHexString)
.asRequest(of: ConfigDump.Variant.self)
.fetchSet(db)
}
.defaulting(to: [])
.map { variant -> [String] in
/// Extract all existing hashes for any dumps associated with the given `sessionIdHexString`
dependencies[cache: .sessionUtil]
.config(for: variant, sessionId: SessionId(hex: sessionIdHexString, dumpVariant: variant))
.wrappedValue
.map { $0.currentHashes() }
.defaulting(to: [])
}
.reduce([], +)
}
// MARK: - Receiving
public static func handleConfigMessages(
_ db: Database,
sessionIdHexString: String,
messages: [ConfigMessageReceiveJob.Details.MessageInfo],
using dependencies: Dependencies = Dependencies()
) throws {
guard !messages.isEmpty else { return }
let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages
.grouped(by: { ConfigDump.Variant(namespace: $0.namespace) })
let needsPush: Bool = try groupedMessages
.sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder }
.reduce(false) { prevNeedsPush, next -> Bool in
let sessionId: SessionId = SessionId(hex: sessionIdHexString, dumpVariant: next.key)
let needsPush: Bool = try dependencies[cache: .sessionUtil]
.config(for: next.key, sessionId: sessionId)
.mutate { config in
do {
// Merge the messages (if it doesn't merge anything then don't bother trying
// to handle the result)
guard let latestServerTimestampMs: Int64 = try config?.merge(next.value) else {
return config.needsPush
}
// Apply the updated states to the database
switch next.key {
case .userProfile:
try SessionUtil.handleUserProfileUpdate(
db,
in: config,
serverTimestampMs: latestServerTimestampMs,
using: dependencies
)
case .contacts:
try SessionUtil.handleContactsUpdate(
db,
in: config,
serverTimestampMs: latestServerTimestampMs,
using: dependencies
)
case .convoInfoVolatile:
try SessionUtil.handleConvoInfoVolatileUpdate(
db,
in: config,
using: dependencies
)
case .userGroups:
try SessionUtil.handleUserGroupsUpdate(
db,
in: config,
serverTimestampMs: latestServerTimestampMs,
using: dependencies
)
case .groupInfo:
try SessionUtil.handleGroupInfoUpdate(
db,
in: config,
groupSessionId: sessionId,
serverTimestampMs: latestServerTimestampMs,
using: dependencies
)
case .groupMembers:
try SessionUtil.handleGroupMembersUpdate(
db,
in: config,
groupSessionId: sessionId,
serverTimestampMs: latestServerTimestampMs,
using: dependencies
)
case .groupKeys:
try SessionUtil.handleGroupKeysUpdate(
db,
in: config,
groupSessionId: sessionId,
using: dependencies
)
case .invalid: SNLog("[libSession] Failed to process merge of invalid config namespace")
}
// Need to check if the config needs to be dumped (this might have changed
// after handling the merge changes)
guard config.needsDump(using: dependencies) else {
try ConfigDump
.filter(
ConfigDump.Columns.variant == next.key &&
ConfigDump.Columns.publicKey == sessionId.hexString
)
.updateAll(
db,
ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs)
)
return config.needsPush
}
try SessionUtil.createDump(
config: config,
for: next.key,
sessionId: sessionId,
timestampMs: latestServerTimestampMs,
using: dependencies
)?.upsert(db)
}
catch {
SNLog("[SessionUtil] Failed to process merge of \(next.key) config data")
throw error
}
return config.needsPush
}
// Update the 'needsPush' state as needed
return (prevNeedsPush || needsPush)
}
// Now that the local state has been updated, schedule a config sync if needed (this will
// push any pending updates and properly update the state)
guard needsPush else { return }
db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(sessionIdHexString)) { db in
ConfigurationSyncJob.enqueue(db, sessionIdHexString: sessionIdHexString)
}
}
}
// MARK: - Convenience
public extension SessionUtil {
static func parseCommunity(url: String) -> (room: String, server: String, publicKey: String)? {
var cFullUrl: [CChar] = url.cArray.nullTerminated()
var cBaseUrl: [CChar] = [CChar](repeating: 0, count: SessionUtil.sizeMaxCommunityBaseUrlBytes)
var cRoom: [CChar] = [CChar](repeating: 0, count: SessionUtil.sizeMaxCommunityRoomBytes)
var cPubkey: [UInt8] = [UInt8](repeating: 0, count: SessionUtil.sizeCommunityPubkeyBytes)
guard
community_parse_full_url(&cFullUrl, &cBaseUrl, &cRoom, &cPubkey) &&
!String(cString: cRoom).isEmpty &&
!String(cString: cBaseUrl).isEmpty &&
cPubkey.contains(where: { $0 != 0 })
else { return nil }
// Note: Need to store them in variables instead of returning directly to ensure they
// don't get freed from memory early (was seeing this happen intermittently during
// unit tests...)
let room: String = String(cString: cRoom)
let baseUrl: String = String(cString: cBaseUrl)
let pubkeyHex: String = Data(cPubkey).toHexString()
return (room, baseUrl, pubkeyHex)
}
static func communityUrlFor(server: String, roomToken: String, publicKey: String) -> String {
var cBaseUrl: [CChar] = server.cArray.nullTerminated()
var cRoom: [CChar] = roomToken.cArray.nullTerminated()
var cPubkey: [UInt8] = Data(hex: publicKey).cArray
var cFullUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_FULL_URL_MAX_LENGTH)
community_make_full_url(&cBaseUrl, &cRoom, &cPubkey, &cFullUrl)
return String(cString: cFullUrl)
}
}
// MARK: - Convenience
private extension Optional where Wrapped == Int32 {
func toConfig(
_ maybeConf: UnsafeMutablePointer<config_object>?,
variant: ConfigDump.Variant,
error: [CChar]
) throws -> SessionUtil.Config {
guard self == 0, let conf: UnsafeMutablePointer<config_object> = maybeConf else {
SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object: \(String(cString: error))")
throw SessionUtilError.unableToCreateConfigObject
}
switch variant {
case .userProfile, .contacts, .convoInfoVolatile,
.userGroups, .groupInfo, .groupMembers:
return .object(conf)
case .groupKeys, .invalid: throw SessionUtilError.unableToCreateConfigObject
}
}
}
private extension Int32 {
func toConfig(
_ maybeConf: UnsafeMutablePointer<config_group_keys>?,
info: UnsafeMutablePointer<config_object>,
members: UnsafeMutablePointer<config_object>,
variant: ConfigDump.Variant,
error: [CChar]
) throws -> SessionUtil.Config {
guard self == 0, let conf: UnsafeMutablePointer<config_group_keys> = maybeConf else {
SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object: \(String(cString: error))")
throw SessionUtilError.unableToCreateConfigObject
}
switch variant {
case .groupKeys: return .groupKeys(conf, info: info, members: members)
default: throw SessionUtilError.unableToCreateConfigObject
}
}
}
private extension SessionId {
init(hex: String, dumpVariant: ConfigDump.Variant) {
switch (try? SessionId(from: hex), dumpVariant) {
case (.some(let sessionId), _): self = sessionId
case (_, .userProfile), (_, .contacts), (_, .convoInfoVolatile), (_, .userGroups):
self = SessionId(.standard, hex: hex)
case (_, .groupInfo), (_, .groupMembers), (_, .groupKeys):
self = SessionId(.group, hex: hex)
case (_, .invalid): self = SessionId.invalid
}
}
}
// MARK: - SessionUtil Cache
public extension SessionUtil {
class Cache: SessionUtilCacheType {
public struct Key: Hashable {
let variant: ConfigDump.Variant
let sessionId: SessionId
}
private var configStore: [SessionUtil.Cache.Key: Atomic<SessionUtil.Config?>] = [:]
public var isEmpty: Bool { configStore.isEmpty }
/// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been
/// loaded yet (eg. fresh install)
public var needsSync: Bool { configStore.contains { _, atomicConf in atomicConf.needsPush } }
// MARK: - Functions
public func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: SessionUtil.Config?) {
configStore[Key(variant: variant, sessionId: sessionId)] = config.map { Atomic($0) }
}
public func config(
for variant: ConfigDump.Variant,
sessionId: SessionId
) -> Atomic<Config?> {
return (
configStore[Key(variant: variant, sessionId: sessionId)] ??
Atomic(nil)
)
}
public func removeAll() {
configStore.removeAll()
}
}
}
public extension Cache {
static let sessionUtil: CacheConfig<SessionUtilCacheType, SessionUtilImmutableCacheType> = Dependencies.create(
identifier: "sessionUtil",
createInstance: { _ in SessionUtil.Cache() },
mutableInstance: { $0 },
immutableInstance: { $0 }
)
}
// MARK: - SessionUtilCacheType
/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way
public protocol SessionUtilImmutableCacheType: ImmutableCacheType {
var isEmpty: Bool { get }
var needsSync: Bool { get }
func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> Atomic<SessionUtil.Config?>
}
public protocol SessionUtilCacheType: SessionUtilImmutableCacheType, MutableCacheType {
var isEmpty: Bool { get }
var needsSync: Bool { get }
func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: SessionUtil.Config?)
func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> Atomic<SessionUtil.Config?>
func removeAll()
}