|
|
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
import GRDB
|
|
|
|
import PromiseKit
|
|
|
|
import SignalCoreKit
|
|
|
|
|
|
|
|
public final class Storage {
|
|
|
|
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 { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)" }
|
|
|
|
private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" }
|
|
|
|
private static var databasePathWal: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-wal" }
|
|
|
|
|
|
|
|
public static var isDatabasePasswordAccessible: Bool {
|
|
|
|
guard (try? getDatabaseCipherKeySpec()) != nil else { return false }
|
|
|
|
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
public static let shared: Storage = Storage()
|
|
|
|
public private(set) var isValid: Bool = false
|
|
|
|
public private(set) var hasCompletedMigrations: Bool = false
|
|
|
|
|
|
|
|
private var dbWriter: DatabaseWriter?
|
|
|
|
private var migrator: DatabaseMigrator?
|
|
|
|
private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>?
|
|
|
|
|
|
|
|
// MARK: - Initialization
|
|
|
|
|
|
|
|
public init(
|
|
|
|
customWriter: DatabaseWriter? = nil,
|
|
|
|
customMigrations: [TargetMigrations]? = nil
|
|
|
|
) {
|
|
|
|
// 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(Storage.sharedDatabaseDirectoryPath)
|
|
|
|
OWSFileSystem.protectFileOrFolder(atPath: Storage.sharedDatabaseDirectoryPath)
|
|
|
|
|
|
|
|
// If a custom writer was provided then use that (for unit testing)
|
|
|
|
guard customWriter == nil else {
|
|
|
|
dbWriter = customWriter
|
|
|
|
isValid = true
|
|
|
|
perform(migrations: (customMigrations ?? []), async: false, onProgressUpdate: nil, onComplete: { _, _ in })
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 = Storage.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 = Storage.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 {
|
|
|
|
dbWriter = try DatabasePool(
|
|
|
|
path: "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)",
|
|
|
|
configuration: config
|
|
|
|
)
|
|
|
|
isValid = true
|
|
|
|
}
|
|
|
|
catch {}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Migrations
|
|
|
|
|
|
|
|
public func perform(
|
|
|
|
migrations: [TargetMigrations],
|
|
|
|
async: Bool = true,
|
|
|
|
onProgressUpdate: ((CGFloat, TimeInterval) -> ())?,
|
|
|
|
onComplete: @escaping (Bool, Bool) -> ()
|
|
|
|
) {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter 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? dbWriter.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)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Store the logic to run when the migration completes
|
|
|
|
let migrationCompleted: (Error?) -> () = { [weak self] error in
|
|
|
|
self?.hasCompletedMigrations = true
|
|
|
|
self?.migrationProgressUpdater = nil
|
|
|
|
SUKLegacy.clearLegacyDatabaseInstance()
|
|
|
|
// SUKLegacy.deleteLegacyDatabaseFilesAndKey() // TODO: Add a "Delete legacy database" migration to run after the '003' migrations
|
|
|
|
|
|
|
|
if let error = error {
|
|
|
|
SNLog("[Migration Error] Migration failed with error: \(error)")
|
|
|
|
}
|
|
|
|
|
|
|
|
onComplete((error == nil), needsConfigSync)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Note: The non-async migration should only be used for unit tests
|
|
|
|
guard async else {
|
|
|
|
do { try self.migrator?.migrate(dbWriter) }
|
|
|
|
catch { migrationCompleted(error) }
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
self.migrator?.asyncMigrate(dbWriter) { _, error in
|
|
|
|
migrationCompleted(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public static func update(
|
|
|
|
progress: CGFloat,
|
|
|
|
for migration: Migration.Type,
|
|
|
|
in target: TargetMigrations.Identifier
|
|
|
|
) {
|
|
|
|
// In test builds ignore any migration progress updates (we run in a custom database writer anyway),
|
|
|
|
// this code should be the same as 'CurrentAppContext().isRunningTests' but since the tests can run
|
|
|
|
// without being attached to a host application the `CurrentAppContext` might not have been set and
|
|
|
|
// would crash as it gets force-unwrapped - better to just do the check explicitly instead
|
|
|
|
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
|
|
|
|
|
|
|
|
Storage.shared.migrationProgressUpdater?.wrappedValue(target.key(with: migration), progress)
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Security
|
|
|
|
|
|
|
|
private static func getDatabaseCipherKeySpec() throws -> Data {
|
|
|
|
return try SSKDefaultKeychainStorage.shared.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 SSKDefaultKeychainStorage.shared.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() {
|
|
|
|
// Just in case they haven't been removed for some reason, delete the legacy database & keys
|
|
|
|
SUKLegacy.clearLegacyDatabaseInstance()
|
|
|
|
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
|
|
|
|
|
|
|
Storage.shared.isValid = false
|
|
|
|
Storage.shared.hasCompletedMigrations = false
|
|
|
|
Storage.shared.dbWriter = nil
|
|
|
|
|
|
|
|
self.deleteDatabaseFiles()
|
|
|
|
try? self.deleteDbKeys()
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Change these back to private
|
|
|
|
public/*private*/ static func deleteDatabaseFiles() {
|
|
|
|
OWSFileSystem.deleteFile(databasePath)
|
|
|
|
OWSFileSystem.deleteFile(databasePathShm)
|
|
|
|
OWSFileSystem.deleteFile(databasePathWal)
|
|
|
|
}
|
|
|
|
|
|
|
|
public/*private*/ static func deleteDbKeys() throws {
|
|
|
|
try SSKDefaultKeychainStorage.shared.remove(service: keychainService, key: dbCipherKeySpecKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Functions
|
|
|
|
|
|
|
|
@discardableResult public func write<T>(updates: (Database) throws -> T?) -> T? {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
|
|
|
|
|
|
|
return try? dbWriter.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 dbWriter: DatabaseWriter = dbWriter else { return }
|
|
|
|
|
|
|
|
dbWriter.asyncWrite(
|
|
|
|
updates,
|
|
|
|
completion: { db, result in
|
|
|
|
try? completion(db, result)
|
|
|
|
}
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult public func read<T>(_ value: (Database) throws -> T?) -> T? {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
|
|
|
|
|
|
|
return try? dbWriter.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 dbWriter: DatabaseWriter = dbWriter else { return AnyDatabaseCancellable(cancel: {}) }
|
|
|
|
|
|
|
|
return observation.start(
|
|
|
|
in: dbWriter,
|
|
|
|
scheduling: scheduler,
|
|
|
|
onError: onError,
|
|
|
|
onChange: onChange
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
public func addObserver(_ observer: TransactionObserver?) {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
|
|
|
|
guard let observer: TransactionObserver = observer else { return }
|
|
|
|
|
|
|
|
dbWriter.add(transactionObserver: observer)
|
|
|
|
}
|
|
|
|
|
|
|
|
public func removeObserver(_ observer: TransactionObserver?) {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
|
|
|
|
guard let observer: TransactionObserver = observer else { return }
|
|
|
|
|
|
|
|
dbWriter.remove(transactionObserver: observer)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Promise Extensions
|
|
|
|
|
|
|
|
public extension Storage {
|
|
|
|
// FIXME: Would be good to replace these with Swift Combine
|
|
|
|
@discardableResult func read<T>(_ value: (Database) throws -> Promise<T>) -> Promise<T> {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
|
|
|
return Promise(error: StorageError.databaseInvalid)
|
|
|
|
}
|
|
|
|
|
|
|
|
do {
|
|
|
|
return try dbWriter.read(value)
|
|
|
|
}
|
|
|
|
catch {
|
|
|
|
return Promise(error: error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@discardableResult func writeAsync<T>(updates: @escaping (Database) throws -> Promise<T>) -> Promise<T> {
|
|
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
|
|
|
return Promise(error: StorageError.databaseInvalid)
|
|
|
|
}
|
|
|
|
|
|
|
|
let (promise, seal) = Promise<T>.pending()
|
|
|
|
|
|
|
|
dbWriter.asyncWrite(
|
|
|
|
{ db in
|
|
|
|
try updates(db)
|
|
|
|
.done { result in seal.fulfill(result) }
|
|
|
|
.catch { error in seal.reject(error) }
|
|
|
|
.retainUntilComplete()
|
|
|
|
},
|
|
|
|
completion: { _, result in
|
|
|
|
switch result {
|
|
|
|
case .failure(let error): seal.reject(error)
|
|
|
|
default: break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
return promise
|
|
|
|
}
|
|
|
|
}
|