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.
302 lines
13 KiB
Swift
302 lines
13 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import PromiseKit
|
|
import SignalCoreKit
|
|
|
|
public enum GRDBStorageError: Error { // TODO: Rename to `StorageError`
|
|
case generic
|
|
case migrationFailed
|
|
case invalidKeySpec
|
|
case decodingFailed
|
|
|
|
case failedToSave
|
|
case objectNotFound
|
|
case objectNotSaved
|
|
|
|
case invalidSearchPattern
|
|
}
|
|
|
|
// TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'?
|
|
|
|
// TODO: Rename to `Storage`
|
|
public final class GRDBStorage {
|
|
public static var shared: GRDBStorage! // TODO: Figure out how/if we want to do this
|
|
|
|
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
|
|
}
|
|
|
|
private let dbPool: DatabasePool
|
|
private let migrator: DatabaseMigrator
|
|
|
|
// MARK: - Initialization
|
|
|
|
public init?(
|
|
migrations: [TargetMigrations]
|
|
) throws {
|
|
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 GRDBStorageError.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
|
|
dbPool = try DatabasePool(
|
|
path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)",
|
|
configuration: config
|
|
)
|
|
|
|
// Setup and run any required migrations
|
|
migrator = {
|
|
var migrator: DatabaseMigrator = DatabaseMigrator()
|
|
migrations
|
|
.sorted()
|
|
.reduce(into: [[(identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet)]]()) { result, next in
|
|
next.migrations.enumerated().forEach { index, migrationSet in
|
|
if result.count <= index {
|
|
result.append([])
|
|
}
|
|
|
|
result[index] = (result[index] + [(next.identifier, migrationSet)])
|
|
}
|
|
}
|
|
.compactMap { $0 }
|
|
.forEach { sortedMigrationInfo in
|
|
sortedMigrationInfo.forEach { migrationInfo in
|
|
migrationInfo.migrations.forEach { migration in
|
|
migrator.registerMigration(migrationInfo.identifier, migration: migration)
|
|
}
|
|
}
|
|
}
|
|
|
|
return migrator
|
|
}()
|
|
try! migrator.migrate(dbPool)
|
|
|
|
GRDBStorage.shared = self // TODO: Fix this
|
|
}
|
|
|
|
// 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 GRDBStorageError.invalidKeySpec }
|
|
|
|
return keySpec
|
|
}
|
|
catch {
|
|
print("RAWR \(error.localizedDescription), \((error as? KeychainStorageError)?.code), \(errSecItemNotFound)")
|
|
|
|
switch (error, (error as? KeychainStorageError)?.code) {
|
|
// TODO: Are there other errors we know about that indicate an invalid keychain?
|
|
// errSecNotAvailable: OSStatus { get } /* No keychain is available. You may need to restart your computer. */
|
|
// public var errSecNoSuchKeychain
|
|
|
|
//errSecInteractionNotAllowed
|
|
|
|
case (GRDBStorageError.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
|
|
// TODO: Check what this 'isRunningTests' does (use the approach to check if XCTTestCase exists instead?)
|
|
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
|
|
|
|
private 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? {
|
|
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) {
|
|
dbPool.asyncWrite(
|
|
updates,
|
|
completion: { db, result in
|
|
try? completion(db, result)
|
|
}
|
|
)
|
|
}
|
|
|
|
@discardableResult public func read<T>(_ value: (Database) throws -> T?) -> T? {
|
|
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 {
|
|
observation.start(
|
|
in: dbPool,
|
|
scheduling: scheduler,
|
|
onError: onError,
|
|
onChange: onChange
|
|
)
|
|
}
|
|
|
|
public func addObserver(_ observer: TransactionObserver?) {
|
|
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> {
|
|
do {
|
|
return try dbPool.read(value)
|
|
}
|
|
catch {
|
|
return Promise(error: error)
|
|
}
|
|
}
|
|
|
|
@discardableResult func write<T>(updates: (Database) throws -> Promise<T>) -> Promise<T> {
|
|
do {
|
|
return try dbPool.write(updates)
|
|
}
|
|
catch {
|
|
return Promise(error: error)
|
|
}
|
|
}
|
|
}
|