mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			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.
		
		
		
		
		
			
		
			
				
	
	
		
			364 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			364 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import GRDB
 | |
| import PromiseKit
 | |
| import SignalCoreKit
 | |
| 
 | |
| 
 | |
| public final class GRDBStorage {
 | |
|     private static let dbFileName: String = "Session.sqlite"
 | |
|     private static let keychainService: String = "TSKeyChainService"
 | |
|     private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec"
 | |
|     private static let kSQLCipherKeySpecLength: Int32 = 48
 | |
|     
 | |
|     private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" }
 | |
|     private static var databasePath: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)" }
 | |
|     private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" }
 | |
|     private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" }
 | |
|     
 | |
|     public static var isDatabasePasswordAccessible: Bool {
 | |
|         guard (try? getDatabaseCipherKeySpec()) != nil else { return false }
 | |
|         
 | |
|         return true
 | |
|     }
 | |
|     
 | |
|     public static let shared: GRDBStorage = GRDBStorage()
 | |
|     public private(set) var isValid: Bool = false
 | |
|     public private(set) var hasCompletedMigrations: Bool = false
 | |
|     
 | |
|     private var dbPool: DatabasePool?
 | |
|     private var migrator: DatabaseMigrator?
 | |
|     private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>?
 | |
|     
 | |
|     // MARK: - Initialization
 | |
|     
 | |
|     public init() {
 | |
|         print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")")
 | |
|         GRDBStorage.deleteDatabaseFiles() // TODO: Remove this
 | |
|         try! GRDBStorage.deleteDbKeys() // TODO: Remove this
 | |
|         
 | |
|         // Create the database directory if needed and ensure it's protection level is set before attempting to
 | |
|         // create the database KeySpec or the database itself
 | |
|         OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath)
 | |
|         OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath)
 | |
|         
 | |
|         // Generate the database KeySpec if needed (this MUST be done before we try to access the database
 | |
|         // as a different thread might attempt to access the database before the key is successfully created)
 | |
|         //
 | |
|         // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang
 | |
|         // around in memory unintentionally
 | |
|         var tmpKeySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec()
 | |
|         tmpKeySpec.resetBytes(in: 0..<tmpKeySpec.count)
 | |
|         
 | |
|         // Configure the database and create the DatabasePool for interacting with the database
 | |
|         var config = Configuration()
 | |
|         config.maximumReaderCount = 10  // Increase the max read connection limit - Default is 5
 | |
|         config.prepareDatabase { db in
 | |
|             var keySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec()
 | |
|             defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
 | |
|             // Use a raw key spec, where the 96 hexadecimal digits are provided
 | |
|             // (i.e. 64 hex for the 256 bit key, followed by 32 hex for the 128 bit salt)
 | |
|             // using explicit BLOB syntax, e.g.:
 | |
|             //
 | |
|             // x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101'
 | |
|             keySpec = try (keySpec.toHexString().data(using: .utf8) ?? { throw StorageError.invalidKeySpec }())
 | |
|             keySpec.insert(contentsOf: [120, 39], at: 0)    // "x'" prefix
 | |
|             keySpec.append(39)                              // "'" suffix
 | |
|             
 | |
|             try db.usePassphrase(keySpec)
 | |
|             
 | |
|             // According to the SQLCipher docs iOS needs the 'cipher_plaintext_header_size' value set to at least
 | |
|             // 32 as iOS extends special privileges to the database and needs this header to be in plaintext
 | |
|             // to determine the file type
 | |
|             //
 | |
|             // For more info see: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_plaintext_header_size
 | |
|             try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32")
 | |
|         }
 | |
|         
 | |
|         // Create the DatabasePool to allow us to connect to the database and mark the storage as valid
 | |
|         do {
 | |
|             dbPool = try DatabasePool(
 | |
|                 path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)",
 | |
|                 configuration: config
 | |
|             )
 | |
|             isValid = true
 | |
|         }
 | |
|         catch {}
 | |
|     }
 | |
|     
 | |
|     // MARK: - Migrations
 | |
|     
 | |
|     public func perform(
 | |
|         migrations: [TargetMigrations],
 | |
|         onProgressUpdate: ((CGFloat, TimeInterval) -> ())?,
 | |
|         onComplete: @escaping (Bool, Bool) -> ()
 | |
|     ) {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return }
 | |
|         
 | |
|         typealias MigrationInfo = (identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet)
 | |
|         let sortedMigrationInfo: [MigrationInfo] = migrations
 | |
|             .sorted()
 | |
|             .reduce(into: [[MigrationInfo]]()) { result, next in
 | |
|                 next.migrations.enumerated().forEach { index, migrationSet in
 | |
|                     if result.count <= index {
 | |
|                         result.append([])
 | |
|                     }
 | |
| 
 | |
|                     result[index] = (result[index] + [(next.identifier, migrationSet)])
 | |
|                 }
 | |
|             }
 | |
|             .reduce(into: []) { result, next in result.append(contentsOf: next) }
 | |
|         
 | |
|         // Setup and run any required migrations
 | |
|         migrator = {
 | |
|             var migrator: DatabaseMigrator = DatabaseMigrator()
 | |
|             sortedMigrationInfo.forEach { migrationInfo in
 | |
|                 migrationInfo.migrations.forEach { migration in
 | |
|                     migrator.registerMigration(migrationInfo.identifier, migration: migration)
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             return migrator
 | |
|         }()
 | |
|         
 | |
|         // Determine which migrations need to be performed and gather the relevant settings needed to
 | |
|         // inform the app of progress/states
 | |
|         let completedMigrations: [String] = (try? dbPool.read { db in try migrator?.completedMigrations(db) })
 | |
|             .defaulting(to: [])
 | |
|         let unperformedMigrations: [(key: String, migration: Migration.Type)] = sortedMigrationInfo
 | |
|             .reduce(into: []) { result, next in
 | |
|                 next.migrations.forEach { migration in
 | |
|                     let key: String = next.identifier.key(with: migration)
 | |
|                     
 | |
|                     guard !completedMigrations.contains(key) else { return }
 | |
|                     
 | |
|                     result.append((key, migration))
 | |
|                 }
 | |
|             }
 | |
|         let migrationToDurationMap: [String: TimeInterval] = unperformedMigrations
 | |
|             .reduce(into: [:]) { result, next in
 | |
|                 result[next.key] = next.migration.minExpectedRunDuration
 | |
|             }
 | |
|         let unperformedMigrationDurations: [TimeInterval] = unperformedMigrations
 | |
|             .map { _, migration in migration.minExpectedRunDuration }
 | |
|         let totalMinExpectedDuration: TimeInterval = migrationToDurationMap.values.reduce(0, +)
 | |
|         let needsConfigSync: Bool = unperformedMigrations
 | |
|             .contains(where: { _, migration in migration.needsConfigSync })
 | |
|         
 | |
|         self.migrationProgressUpdater = Atomic({ targetKey, progress in
 | |
|             guard let migrationIndex: Int = unperformedMigrations.firstIndex(where: { key, _ in key == targetKey }) else {
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             let completedExpectedDuration: TimeInterval = (
 | |
|                 (migrationIndex > 0 ? unperformedMigrationDurations[0..<migrationIndex].reduce(0, +) : 0) +
 | |
|                 (unperformedMigrationDurations[migrationIndex] * progress)
 | |
|             )
 | |
|             let totalProgress: CGFloat = (completedExpectedDuration / totalMinExpectedDuration)
 | |
|             
 | |
|             DispatchQueue.main.async {
 | |
|                 onProgressUpdate?(totalProgress, totalMinExpectedDuration)
 | |
|             }
 | |
|         })
 | |
|         
 | |
|         // If we have an unperformed migration then trigger the progress updater immediately
 | |
|         if let firstMigrationKey: String = unperformedMigrations.first?.key {
 | |
|             self.migrationProgressUpdater?.wrappedValue(firstMigrationKey, 0)
 | |
|         }
 | |
|         
 | |
|         self.migrator?.asyncMigrate(dbPool) { [weak self] _, error in
 | |
|             self?.hasCompletedMigrations = true
 | |
|             self?.migrationProgressUpdater = nil
 | |
|             SUKLegacy.clearLegacyDatabaseInstance()
 | |
|             
 | |
|             onComplete((error == nil), needsConfigSync)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     public func update(
 | |
|         progress: CGFloat,
 | |
|         for migration: Migration.Type,
 | |
|         in target: TargetMigrations.Identifier
 | |
|     ) {
 | |
|         GRDBStorage.shared.migrationProgressUpdater?.wrappedValue(target.key(with: migration), progress)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Security
 | |
|     
 | |
|     private static func getDatabaseCipherKeySpec() throws -> Data {
 | |
|         return try CurrentAppContext().keychainStorage().data(forService: keychainService, key: dbCipherKeySpecKey)
 | |
|     }
 | |
|     
 | |
|     @discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data {
 | |
|         do {
 | |
|             var keySpec: Data = try getDatabaseCipherKeySpec()
 | |
|             defer { keySpec.resetBytes(in: 0..<keySpec.count) }
 | |
|             
 | |
|             guard keySpec.count == kSQLCipherKeySpecLength else { throw StorageError.invalidKeySpec }
 | |
|             
 | |
|             return keySpec
 | |
|         }
 | |
|         catch {
 | |
|             switch (error, (error as? KeychainStorageError)?.code) {
 | |
|                 case (StorageError.invalidKeySpec, _):
 | |
|                     // For these cases it means either the keySpec or the keychain has become corrupt so in order to
 | |
|                     // get back to a "known good state" and behave like a new install we need to reset the storage
 | |
|                     // and regenerate the key
 | |
|                     if !CurrentAppContext().isRunningTests {
 | |
|                         // Try to reset app by deleting database.
 | |
|                         resetAllStorage()
 | |
|                     }
 | |
|                     fallthrough
 | |
|                 
 | |
|                 case (_, errSecItemNotFound):
 | |
|                     // No keySpec was found so we need to generate a new one
 | |
|                     do {
 | |
|                         var keySpec: Data = Randomness.generateRandomBytes(kSQLCipherKeySpecLength)
 | |
|                         defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
 | |
|                         
 | |
|                         try CurrentAppContext().keychainStorage().set(data: keySpec, service: keychainService, key: dbCipherKeySpecKey)
 | |
|                         print("RAWR new keySpec generated and saved")
 | |
|                         return keySpec
 | |
|                     }
 | |
|                     catch {
 | |
|                         Thread.sleep(forTimeInterval: 15)    // Sleep to allow any background behaviours to complete
 | |
|                         fatalError("Setting keychain value failed with error: \(error.localizedDescription)")
 | |
|                     }
 | |
|                     
 | |
|                 default:
 | |
|                     // Because we use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, the keychain will be inaccessible
 | |
|                     // after device restart until device is unlocked for the first time. If the app receives a push
 | |
|                     // notification, we won't be able to access the keychain to process that notification, so we should
 | |
|                     // just terminate by throwing an uncaught exception
 | |
|                     if CurrentAppContext().isMainApp || CurrentAppContext().isInBackground() {
 | |
|                         let appState: UIApplication.State = CurrentAppContext().reportedApplicationState
 | |
|                         
 | |
|                         // In this case we should have already detected the situation earlier and exited gracefully (in the
 | |
|                         // app delegate) using isDatabasePasswordAccessible, but we want to stop the app running here anyway
 | |
|                         Thread.sleep(forTimeInterval: 5)    // Sleep to allow any background behaviours to complete
 | |
|                         fatalError("CipherKeySpec inaccessible. New install or no unlock since device restart?, ApplicationState: \(NSStringForUIApplicationState(appState))")
 | |
|                     }
 | |
|                     
 | |
|                     Thread.sleep(forTimeInterval: 5)    // Sleep to allow any background behaviours to complete
 | |
|                     fatalError("CipherKeySpec inaccessible; not main app.")
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - File Management
 | |
|     
 | |
|     public static func resetAllStorage() {
 | |
|         NotificationCenter.default.post(name: .resetStorage, object: nil)
 | |
|         
 | |
|         // This might be redundant but in the spirit of thoroughness...
 | |
|         self.deleteDatabaseFiles()
 | |
| 
 | |
|         try? self.deleteDbKeys()
 | |
| 
 | |
|         if CurrentAppContext().isMainApp {
 | |
| //            TSAttachmentStream.deleteAttachments()
 | |
|         }
 | |
| 
 | |
|         // TODO: Delete Profiles on Disk?
 | |
|     }
 | |
|     
 | |
|     private static func deleteDatabaseFiles() {
 | |
|         OWSFileSystem.deleteFile(databasePath)
 | |
|         OWSFileSystem.deleteFile(databasePathShm)
 | |
|         OWSFileSystem.deleteFile(databasePathWal)
 | |
|     }
 | |
|     
 | |
|     private static func deleteDbKeys() throws {
 | |
|         try CurrentAppContext().keychainStorage().remove(service: keychainService, key: dbCipherKeySpecKey)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Functions
 | |
|     
 | |
|     @discardableResult public func write<T>(updates: (Database) throws -> T?) -> T? {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return nil }
 | |
|         
 | |
|         return try? dbPool.write(updates)
 | |
|     }
 | |
|     
 | |
|     public func writeAsync<T>(updates: @escaping (Database) throws -> T) {
 | |
|         writeAsync(updates: updates, completion: { _, _ in })
 | |
|     }
 | |
|     
 | |
|     public func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return }
 | |
|         
 | |
|         dbPool.asyncWrite(
 | |
|             updates,
 | |
|             completion: { db, result in
 | |
|                 try? completion(db, result)
 | |
|             }
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     @discardableResult public func read<T>(_ value: (Database) throws -> T?) -> T? {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return nil }
 | |
|         
 | |
|         return try? dbPool.read(value)
 | |
|     }
 | |
|     
 | |
|     /// Rever to the `ValueObservation.start` method for full documentation
 | |
|     ///
 | |
|     /// - parameter observation: The observation to start
 | |
|     /// - parameter scheduler: A Scheduler. By default, fresh values are
 | |
|     ///   dispatched asynchronously on the main queue.
 | |
|     /// - parameter onError: A closure that is provided eventual errors that
 | |
|     ///   happen during observation
 | |
|     /// - parameter onChange: A closure that is provided fresh values
 | |
|     /// - returns: a DatabaseCancellable
 | |
|     public func start<Reducer: ValueReducer>(
 | |
|         _ observation: ValueObservation<Reducer>,
 | |
|         scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main),
 | |
|         onError: @escaping (Error) -> Void,
 | |
|         onChange: @escaping (Reducer.Value) -> Void
 | |
|     ) -> DatabaseCancellable {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return AnyDatabaseCancellable(cancel: {}) }
 | |
|         
 | |
|         return observation.start(
 | |
|             in: dbPool,
 | |
|             scheduling: scheduler,
 | |
|             onError: onError,
 | |
|             onChange: onChange
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     public func addObserver(_ observer: TransactionObserver?) {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return }
 | |
|         guard let observer: TransactionObserver = observer else { return }
 | |
|         
 | |
|         dbPool.add(transactionObserver: observer)
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Promise Extensions
 | |
| 
 | |
| public extension GRDBStorage {
 | |
|     // FIXME: Would be good to replace these with Swift Combine
 | |
|     @discardableResult func read<T>(_ value: (Database) throws -> Promise<T>) -> Promise<T> {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) }
 | |
|         
 | |
|         do {
 | |
|             return try dbPool.read(value)
 | |
|         }
 | |
|         catch {
 | |
|             return Promise(error: error)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @discardableResult func write<T>(updates: (Database) throws -> Promise<T>) -> Promise<T> {
 | |
|         guard isValid, let dbPool: DatabasePool = dbPool else { return Promise(error: StorageError.databaseInvalid) }
 | |
|         
 | |
|         do {
 | |
|             return try dbPool.write(updates)
 | |
|         }
 | |
|         catch {
 | |
|             return Promise(error: error)
 | |
|         }
 | |
|     }
 | |
| }
 |