Encrypt local databases

This commit is contained in:
Justin Mazzocchi 2020-09-03 23:12:06 -07:00
parent f921d154b3
commit fb4e3f907f
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
11 changed files with 114 additions and 32 deletions

View file

@ -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"])

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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)

View file

@ -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) {

View file

@ -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;
};

View file

@ -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
}
},
{

View file

@ -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
}
}

View file

@ -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()

View file

@ -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)