mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-25 09:41:00 +00:00
Encrypt local databases
This commit is contained in:
parent
f921d154b3
commit
fb4e3f907f
11 changed files with 114 additions and 32 deletions
|
@ -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"])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
40
DB/Sources/DB/Extensions/Secrets+Extensions.swift
Normal file
40
DB/Sources/DB/Extensions/Secrets+Extensions.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue