Encrypt local databases
This commit is contained in:
parent
f921d154b3
commit
fb4e3f907f
|
@ -14,13 +14,14 @@ let package = Package(
|
|||
targets: ["DB"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "GRDB", url: "https://github.com/groue/GRDB.swift.git", .upToNextMajor(from: "5.0.0-beta.10")),
|
||||
.package(path: "Mastodon")
|
||||
.package(name: "GRDB", url: "https://github.com/metabolist/GRDB.swift.git", .revision("ea3ed26")),
|
||||
.package(path: "Mastodon"),
|
||||
.package(path: "Secrets")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "DB",
|
||||
dependencies: ["GRDB", "Mastodon"]),
|
||||
dependencies: ["GRDB", "Mastodon", "Secrets"]),
|
||||
.testTarget(
|
||||
name: "DBTests",
|
||||
dependencies: ["DB"])
|
||||
|
|
|
@ -3,16 +3,26 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import Keychain
|
||||
import Mastodon
|
||||
import Secrets
|
||||
|
||||
public struct ContentDatabase {
|
||||
private let databaseQueue: DatabaseQueue
|
||||
|
||||
public init(identityID: UUID, inMemory: Bool) throws {
|
||||
public init(identityID: UUID, inMemory: Bool, keychain: Keychain.Type) throws {
|
||||
if inMemory {
|
||||
databaseQueue = DatabaseQueue()
|
||||
} else {
|
||||
databaseQueue = try DatabaseQueue(path: try Self.fileURL(identityID: identityID).path)
|
||||
let path = try Self.fileURL(identityID: identityID).path
|
||||
var configuration = Configuration()
|
||||
|
||||
configuration.prepareDatabase = { db in
|
||||
let passphrase = try Secrets.databasePassphrase(identityID: identityID, keychain: keychain)
|
||||
try db.usePassphrase(passphrase)
|
||||
}
|
||||
|
||||
databaseQueue = try DatabaseQueue(path: path, configuration: configuration)
|
||||
}
|
||||
|
||||
try Self.migrate(databaseQueue)
|
||||
|
@ -176,7 +186,7 @@ public extension ContentDatabase {
|
|||
|
||||
private extension ContentDatabase {
|
||||
static func fileURL(identityID: UUID) throws -> URL {
|
||||
try FileManager.default.databaseDirectoryURL().appendingPathComponent(identityID.uuidString + ".sqlite")
|
||||
try FileManager.default.databaseDirectoryURL(name: identityID.uuidString)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
|
|
|
@ -3,12 +3,12 @@
|
|||
import Foundation
|
||||
|
||||
extension FileManager {
|
||||
func databaseDirectoryURL() throws -> URL {
|
||||
func databaseDirectoryURL(name: String) throws -> URL {
|
||||
let databaseDirectoryURL = try url(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true)
|
||||
.appendingPathComponent("Database")
|
||||
.appendingPathComponent("DB")
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
if !fileExists(atPath: databaseDirectoryURL.path, isDirectory: &isDirectory) {
|
||||
|
@ -19,6 +19,6 @@ extension FileManager {
|
|||
throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil)
|
||||
}
|
||||
|
||||
return databaseDirectoryURL
|
||||
return databaseDirectoryURL.appendingPathComponent(name)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Keychain
|
||||
import Secrets
|
||||
|
||||
extension Secrets {
|
||||
private static let passphraseByteCount = 64
|
||||
|
||||
static func databasePassphrase(identityID: UUID?, keychain: Keychain.Type) throws -> String {
|
||||
let scopedSecrets: Secrets?
|
||||
|
||||
if let identityID = identityID {
|
||||
scopedSecrets = Secrets(identityID: identityID, keychain: keychain)
|
||||
} else {
|
||||
scopedSecrets = nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try scopedSecrets?.item(.databasePassphrase) ?? unscopedItem(.databasePassphrase, keychain: keychain)
|
||||
} catch SecretsError.itemAbsent {
|
||||
var bytes = [Int8](repeating: 0, count: passphraseByteCount)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, passphraseByteCount, &bytes)
|
||||
|
||||
if status == errSecSuccess {
|
||||
let passphrase = Data(bytes: bytes, count: passphraseByteCount).base64EncodedString()
|
||||
|
||||
if let scopedSecrets = scopedSecrets {
|
||||
try scopedSecrets.set(passphrase, forItem: .databasePassphrase)
|
||||
} else {
|
||||
try setUnscoped(passphrase, forItem: .databasePassphrase, keychain: keychain)
|
||||
}
|
||||
|
||||
return passphrase
|
||||
} else {
|
||||
throw NSError(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,7 +3,9 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import Keychain
|
||||
import Mastodon
|
||||
import Secrets
|
||||
|
||||
public enum IdentityDatabaseError: Error {
|
||||
case identityNotFound
|
||||
|
@ -12,13 +14,19 @@ public enum IdentityDatabaseError: Error {
|
|||
public struct IdentityDatabase {
|
||||
private let databaseQueue: DatabaseQueue
|
||||
|
||||
public init(inMemory: Bool, fixture: IdentityFixture?) throws {
|
||||
public init(inMemory: Bool, fixture: IdentityFixture?, keychain: Keychain.Type) throws {
|
||||
if inMemory {
|
||||
databaseQueue = DatabaseQueue()
|
||||
} else {
|
||||
let databaseURL = try FileManager.default.databaseDirectoryURL().appendingPathComponent("Identities.sqlite")
|
||||
let path = try FileManager.default.databaseDirectoryURL(name: Self.name).path
|
||||
var configuration = Configuration()
|
||||
|
||||
databaseQueue = try DatabaseQueue(path: databaseURL.path)
|
||||
configuration.prepareDatabase = { db in
|
||||
let passphrase = try Secrets.databasePassphrase(identityID: nil, keychain: keychain)
|
||||
try db.usePassphrase(passphrase)
|
||||
}
|
||||
|
||||
databaseQueue = try DatabaseQueue(path: path, configuration: configuration)
|
||||
}
|
||||
|
||||
try Self.migrate(databaseQueue)
|
||||
|
@ -184,6 +192,8 @@ public extension IdentityDatabase {
|
|||
}
|
||||
|
||||
private extension IdentityDatabase {
|
||||
private static let name = "Identity"
|
||||
|
||||
private static func identitiesRequest() -> QueryInterfaceRequest<IdentityResult> {
|
||||
StoredIdentity
|
||||
.order(Column("lastUsedAt").desc)
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import Foundation
|
||||
|
||||
extension NSError {
|
||||
convenience init(status: OSStatus) {
|
||||
public convenience init(status: OSStatus) {
|
||||
var userInfo: [String: Any]?
|
||||
|
||||
if let errorMessage = SecCopyErrorMessageString(status, nil) {
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */; };
|
||||
D0BECB982501C0FC002C1B13 /* Secrets in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB972501C0FC002C1B13 /* Secrets */; };
|
||||
D0BECB9A2501C15F002C1B13 /* Mastodon in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB992501C15F002C1B13 /* Mastodon */; };
|
||||
D0BECB9C2501C731002C1B13 /* PreviewViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB9B2501C731002C1B13 /* PreviewViewModels */; };
|
||||
D0BECB9F2501D9AD002C1B13 /* PreviewViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB9E2501D9AD002C1B13 /* PreviewViewModels */; };
|
||||
D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; };
|
||||
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; };
|
||||
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; };
|
||||
|
@ -136,7 +136,7 @@
|
|||
files = (
|
||||
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */,
|
||||
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */,
|
||||
D0BECB9C2501C731002C1B13 /* PreviewViewModels in Frameworks */,
|
||||
D0BECB9F2501D9AD002C1B13 /* PreviewViewModels in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -331,7 +331,7 @@
|
|||
packageProductDependencies = (
|
||||
D06B492224D4611300642749 /* KingfisherSwiftUI */,
|
||||
D0E2C1D024FD97F000854680 /* ViewModels */,
|
||||
D0BECB9B2501C731002C1B13 /* PreviewViewModels */,
|
||||
D0BECB9E2501D9AD002C1B13 /* PreviewViewModels */,
|
||||
);
|
||||
productName = "Metatext (iOS)";
|
||||
productReference = D047FA8C24C3E21200AF17C5 /* Metatext.app */;
|
||||
|
@ -863,7 +863,7 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Mastodon;
|
||||
};
|
||||
D0BECB9B2501C731002C1B13 /* PreviewViewModels */ = {
|
||||
D0BECB9E2501D9AD002C1B13 /* PreviewViewModels */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = PreviewViewModels;
|
||||
};
|
||||
|
|
|
@ -21,11 +21,11 @@
|
|||
},
|
||||
{
|
||||
"package": "GRDB",
|
||||
"repositoryURL": "https://github.com/groue/GRDB.swift.git",
|
||||
"repositoryURL": "https://github.com/metabolist/GRDB.swift.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "ededd8668abd5a3c4c43cc9ebcfd611082b47f65",
|
||||
"version": "5.0.0-beta.10"
|
||||
"branch": "ea3ed26",
|
||||
"revision": "ea3ed26ddc82f72c2d9c50111977df7671ca1e64",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -29,10 +29,11 @@ public extension Secrets {
|
|||
case accessToken
|
||||
case pushKey
|
||||
case pushAuth
|
||||
case databasePassphrase
|
||||
}
|
||||
}
|
||||
|
||||
enum SecretsServiceError: Error {
|
||||
public enum SecretsError: Error {
|
||||
case itemAbsent
|
||||
}
|
||||
|
||||
|
@ -51,18 +52,35 @@ extension Secrets.Item {
|
|||
}
|
||||
|
||||
public extension Secrets {
|
||||
static func setUnscoped(_ data: SecretsStorable, forItem item: Item, keychain: Keychain.Type) throws {
|
||||
try keychain.setGenericPassword(
|
||||
data: data.dataStoredInSecrets,
|
||||
forAccount: item.rawValue,
|
||||
service: keychainServiceName)
|
||||
}
|
||||
|
||||
static func unscopedItem<T: SecretsStorable>(_ item: Item, keychain: Keychain.Type) throws -> T {
|
||||
guard let data = try keychain.getGenericPassword(
|
||||
account: item.rawValue,
|
||||
service: Self.keychainServiceName) else {
|
||||
throw SecretsError.itemAbsent
|
||||
}
|
||||
|
||||
return try T.fromDataStoredInSecrets(data)
|
||||
}
|
||||
|
||||
func set(_ data: SecretsStorable, forItem item: Item) throws {
|
||||
try keychain.setGenericPassword(
|
||||
data: data.dataStoredInSecrets,
|
||||
forAccount: key(item: item),
|
||||
forAccount: scopedKey(item: item),
|
||||
service: Self.keychainServiceName)
|
||||
}
|
||||
|
||||
func item<T: SecretsStorable>(_ item: Item) throws -> T {
|
||||
guard let data = try keychain.getGenericPassword(
|
||||
account: key(item: item),
|
||||
account: scopedKey(item: item),
|
||||
service: Self.keychainServiceName) else {
|
||||
throw SecretsServiceError.itemAbsent
|
||||
throw SecretsError.itemAbsent
|
||||
}
|
||||
|
||||
return try T.fromDataStoredInSecrets(data)
|
||||
|
@ -73,23 +91,23 @@ public extension Secrets {
|
|||
switch item.kind {
|
||||
case .genericPassword:
|
||||
try keychain.deleteGenericPassword(
|
||||
account: key(item: item),
|
||||
account: scopedKey(item: item),
|
||||
service: Self.keychainServiceName)
|
||||
case .key:
|
||||
try keychain.deleteKey(applicationTag: key(item: item))
|
||||
try keychain.deleteKey(applicationTag: scopedKey(item: item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generatePushKeyAndReturnPublicKey() throws -> Data {
|
||||
try keychain.generateKeyAndReturnPublicKey(
|
||||
applicationTag: key(item: .pushKey),
|
||||
applicationTag: scopedKey(item: .pushKey),
|
||||
attributes: PushKey.attributes)
|
||||
}
|
||||
|
||||
func getPushKey() throws -> Data? {
|
||||
try keychain.getPrivateKey(
|
||||
applicationTag: key(item: .pushKey),
|
||||
applicationTag: scopedKey(item: .pushKey),
|
||||
attributes: PushKey.attributes)
|
||||
}
|
||||
|
||||
|
@ -113,7 +131,7 @@ public extension Secrets {
|
|||
private extension Secrets {
|
||||
static let keychainServiceName = "com.metabolist.metatext"
|
||||
|
||||
func key(item: Item) -> String {
|
||||
func scopedKey(item: Item) -> String {
|
||||
identityID.uuidString + "." + item.rawValue
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,8 @@ public struct AllIdentitiesService {
|
|||
|
||||
public init(environment: AppEnvironment) throws {
|
||||
self.identityDatabase = try IdentityDatabase(inMemory: environment.inMemoryContent,
|
||||
fixture: environment.identityFixture)
|
||||
fixture: environment.identityFixture,
|
||||
keychain: environment.keychain)
|
||||
self.environment = environment
|
||||
|
||||
mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation()
|
||||
|
|
|
@ -42,7 +42,9 @@ public class IdentityService {
|
|||
mastodonAPIClient.instanceURL = identity.url
|
||||
mastodonAPIClient.accessToken = try? secrets.item(.accessToken)
|
||||
|
||||
contentDatabase = try ContentDatabase(identityID: identityID, inMemory: environment.inMemoryContent)
|
||||
contentDatabase = try ContentDatabase(identityID: identityID,
|
||||
inMemory: environment.inMemoryContent,
|
||||
keychain: environment.keychain)
|
||||
|
||||
observation.catch { [weak self] error -> Empty<Identity, Never> in
|
||||
self?.observationErrorsInput.send(error)
|
||||
|
|
Loading…
Reference in New Issue