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"]) targets: ["DB"])
], ],
dependencies: [ dependencies: [
.package(name: "GRDB", url: "https://github.com/groue/GRDB.swift.git", .upToNextMajor(from: "5.0.0-beta.10")), .package(name: "GRDB", url: "https://github.com/metabolist/GRDB.swift.git", .revision("ea3ed26")),
.package(path: "Mastodon") .package(path: "Mastodon"),
.package(path: "Secrets")
], ],
targets: [ targets: [
.target( .target(
name: "DB", name: "DB",
dependencies: ["GRDB", "Mastodon"]), dependencies: ["GRDB", "Mastodon", "Secrets"]),
.testTarget( .testTarget(
name: "DBTests", name: "DBTests",
dependencies: ["DB"]) dependencies: ["DB"])

View file

@ -3,16 +3,26 @@
import Foundation import Foundation
import Combine import Combine
import GRDB import GRDB
import Keychain
import Mastodon import Mastodon
import Secrets
public struct ContentDatabase { public struct ContentDatabase {
private let databaseQueue: DatabaseQueue private let databaseQueue: DatabaseQueue
public init(identityID: UUID, inMemory: Bool) throws { public init(identityID: UUID, inMemory: Bool, keychain: Keychain.Type) throws {
if inMemory { if inMemory {
databaseQueue = DatabaseQueue() databaseQueue = DatabaseQueue()
} else { } 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) try Self.migrate(databaseQueue)
@ -176,7 +186,7 @@ public extension ContentDatabase {
private extension ContentDatabase { private extension ContentDatabase {
static func fileURL(identityID: UUID) throws -> URL { 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 // swiftlint:disable function_body_length

View file

@ -3,12 +3,12 @@
import Foundation import Foundation
extension FileManager { extension FileManager {
func databaseDirectoryURL() throws -> URL { func databaseDirectoryURL(name: String) throws -> URL {
let databaseDirectoryURL = try url(for: .applicationSupportDirectory, let databaseDirectoryURL = try url(for: .applicationSupportDirectory,
in: .userDomainMask, in: .userDomainMask,
appropriateFor: nil, appropriateFor: nil,
create: true) create: true)
.appendingPathComponent("Database") .appendingPathComponent("DB")
var isDirectory: ObjCBool = false var isDirectory: ObjCBool = false
if !fileExists(atPath: databaseDirectoryURL.path, isDirectory: &isDirectory) { if !fileExists(atPath: databaseDirectoryURL.path, isDirectory: &isDirectory) {
@ -19,6 +19,6 @@ extension FileManager {
throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) 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 Foundation
import Combine import Combine
import GRDB import GRDB
import Keychain
import Mastodon import Mastodon
import Secrets
public enum IdentityDatabaseError: Error { public enum IdentityDatabaseError: Error {
case identityNotFound case identityNotFound
@ -12,13 +14,19 @@ public enum IdentityDatabaseError: Error {
public struct IdentityDatabase { public struct IdentityDatabase {
private let databaseQueue: DatabaseQueue private let databaseQueue: DatabaseQueue
public init(inMemory: Bool, fixture: IdentityFixture?) throws { public init(inMemory: Bool, fixture: IdentityFixture?, keychain: Keychain.Type) throws {
if inMemory { if inMemory {
databaseQueue = DatabaseQueue() databaseQueue = DatabaseQueue()
} else { } 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) try Self.migrate(databaseQueue)
@ -184,6 +192,8 @@ public extension IdentityDatabase {
} }
private extension IdentityDatabase { private extension IdentityDatabase {
private static let name = "Identity"
private static func identitiesRequest() -> QueryInterfaceRequest<IdentityResult> { private static func identitiesRequest() -> QueryInterfaceRequest<IdentityResult> {
StoredIdentity StoredIdentity
.order(Column("lastUsedAt").desc) .order(Column("lastUsedAt").desc)

View file

@ -3,7 +3,7 @@
import Foundation import Foundation
extension NSError { extension NSError {
convenience init(status: OSStatus) { public convenience init(status: OSStatus) {
var userInfo: [String: Any]? var userInfo: [String: Any]?
if let errorMessage = SecCopyErrorMessageString(status, nil) { if let errorMessage = SecCopyErrorMessageString(status, nil) {

View file

@ -19,7 +19,7 @@
D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */; }; D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */; };
D0BECB982501C0FC002C1B13 /* Secrets in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB972501C0FC002C1B13 /* Secrets */; }; D0BECB982501C0FC002C1B13 /* Secrets in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB972501C0FC002C1B13 /* Secrets */; };
D0BECB9A2501C15F002C1B13 /* Mastodon in Frameworks */ = {isa = PBXBuildFile; productRef = D0BECB992501C15F002C1B13 /* Mastodon */; }; 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 */; }; D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; };
D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; }; D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; };
D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; }; D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; };
@ -136,7 +136,7 @@
files = ( files = (
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */, D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */,
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */, D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */,
D0BECB9C2501C731002C1B13 /* PreviewViewModels in Frameworks */, D0BECB9F2501D9AD002C1B13 /* PreviewViewModels in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -331,7 +331,7 @@
packageProductDependencies = ( packageProductDependencies = (
D06B492224D4611300642749 /* KingfisherSwiftUI */, D06B492224D4611300642749 /* KingfisherSwiftUI */,
D0E2C1D024FD97F000854680 /* ViewModels */, D0E2C1D024FD97F000854680 /* ViewModels */,
D0BECB9B2501C731002C1B13 /* PreviewViewModels */, D0BECB9E2501D9AD002C1B13 /* PreviewViewModels */,
); );
productName = "Metatext (iOS)"; productName = "Metatext (iOS)";
productReference = D047FA8C24C3E21200AF17C5 /* Metatext.app */; productReference = D047FA8C24C3E21200AF17C5 /* Metatext.app */;
@ -863,7 +863,7 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Mastodon; productName = Mastodon;
}; };
D0BECB9B2501C731002C1B13 /* PreviewViewModels */ = { D0BECB9E2501D9AD002C1B13 /* PreviewViewModels */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = PreviewViewModels; productName = PreviewViewModels;
}; };

View file

@ -21,11 +21,11 @@
}, },
{ {
"package": "GRDB", "package": "GRDB",
"repositoryURL": "https://github.com/groue/GRDB.swift.git", "repositoryURL": "https://github.com/metabolist/GRDB.swift.git",
"state": { "state": {
"branch": null, "branch": "ea3ed26",
"revision": "ededd8668abd5a3c4c43cc9ebcfd611082b47f65", "revision": "ea3ed26ddc82f72c2d9c50111977df7671ca1e64",
"version": "5.0.0-beta.10" "version": null
} }
}, },
{ {

View file

@ -29,10 +29,11 @@ public extension Secrets {
case accessToken case accessToken
case pushKey case pushKey
case pushAuth case pushAuth
case databasePassphrase
} }
} }
enum SecretsServiceError: Error { public enum SecretsError: Error {
case itemAbsent case itemAbsent
} }
@ -51,18 +52,35 @@ extension Secrets.Item {
} }
public extension Secrets { 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 { func set(_ data: SecretsStorable, forItem item: Item) throws {
try keychain.setGenericPassword( try keychain.setGenericPassword(
data: data.dataStoredInSecrets, data: data.dataStoredInSecrets,
forAccount: key(item: item), forAccount: scopedKey(item: item),
service: Self.keychainServiceName) service: Self.keychainServiceName)
} }
func item<T: SecretsStorable>(_ item: Item) throws -> T { func item<T: SecretsStorable>(_ item: Item) throws -> T {
guard let data = try keychain.getGenericPassword( guard let data = try keychain.getGenericPassword(
account: key(item: item), account: scopedKey(item: item),
service: Self.keychainServiceName) else { service: Self.keychainServiceName) else {
throw SecretsServiceError.itemAbsent throw SecretsError.itemAbsent
} }
return try T.fromDataStoredInSecrets(data) return try T.fromDataStoredInSecrets(data)
@ -73,23 +91,23 @@ public extension Secrets {
switch item.kind { switch item.kind {
case .genericPassword: case .genericPassword:
try keychain.deleteGenericPassword( try keychain.deleteGenericPassword(
account: key(item: item), account: scopedKey(item: item),
service: Self.keychainServiceName) service: Self.keychainServiceName)
case .key: case .key:
try keychain.deleteKey(applicationTag: key(item: item)) try keychain.deleteKey(applicationTag: scopedKey(item: item))
} }
} }
} }
func generatePushKeyAndReturnPublicKey() throws -> Data { func generatePushKeyAndReturnPublicKey() throws -> Data {
try keychain.generateKeyAndReturnPublicKey( try keychain.generateKeyAndReturnPublicKey(
applicationTag: key(item: .pushKey), applicationTag: scopedKey(item: .pushKey),
attributes: PushKey.attributes) attributes: PushKey.attributes)
} }
func getPushKey() throws -> Data? { func getPushKey() throws -> Data? {
try keychain.getPrivateKey( try keychain.getPrivateKey(
applicationTag: key(item: .pushKey), applicationTag: scopedKey(item: .pushKey),
attributes: PushKey.attributes) attributes: PushKey.attributes)
} }
@ -113,7 +131,7 @@ public extension Secrets {
private extension Secrets { private extension Secrets {
static let keychainServiceName = "com.metabolist.metatext" static let keychainServiceName = "com.metabolist.metatext"
func key(item: Item) -> String { func scopedKey(item: Item) -> String {
identityID.uuidString + "." + item.rawValue identityID.uuidString + "." + item.rawValue
} }
} }

View file

@ -15,7 +15,8 @@ public struct AllIdentitiesService {
public init(environment: AppEnvironment) throws { public init(environment: AppEnvironment) throws {
self.identityDatabase = try IdentityDatabase(inMemory: environment.inMemoryContent, self.identityDatabase = try IdentityDatabase(inMemory: environment.inMemoryContent,
fixture: environment.identityFixture) fixture: environment.identityFixture,
keychain: environment.keychain)
self.environment = environment self.environment = environment
mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation() mostRecentlyUsedIdentityID = identityDatabase.mostRecentlyUsedIdentityIDObservation()

View file

@ -42,7 +42,9 @@ public class IdentityService {
mastodonAPIClient.instanceURL = identity.url mastodonAPIClient.instanceURL = identity.url
mastodonAPIClient.accessToken = try? secrets.item(.accessToken) 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 observation.catch { [weak self] error -> Empty<Identity, Never> in
self?.observationErrorsInput.send(error) self?.observationErrorsInput.send(error)