diff --git a/DB/Package.swift b/DB/Package.swift index c058a0a..0e670f4 100644 --- a/DB/Package.swift +++ b/DB/Package.swift @@ -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"]) diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 8933363..2a9bedb 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -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 diff --git a/DB/Sources/DB/Extensions/FileManager+Extensions.swift b/DB/Sources/DB/Extensions/FileManager+Extensions.swift index 127ae8a..360c799 100644 --- a/DB/Sources/DB/Extensions/FileManager+Extensions.swift +++ b/DB/Sources/DB/Extensions/FileManager+Extensions.swift @@ -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) } } diff --git a/DB/Sources/DB/Extensions/Secrets+Extensions.swift b/DB/Sources/DB/Extensions/Secrets+Extensions.swift new file mode 100644 index 0000000..7ab4295 --- /dev/null +++ b/DB/Sources/DB/Extensions/Secrets+Extensions.swift @@ -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) + } + } + } +} diff --git a/DB/Sources/DB/Identity/IdentityDatabase.swift b/DB/Sources/DB/Identity/IdentityDatabase.swift index 847f291..6b2165f 100644 --- a/DB/Sources/DB/Identity/IdentityDatabase.swift +++ b/DB/Sources/DB/Identity/IdentityDatabase.swift @@ -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 { StoredIdentity .order(Column("lastUsedAt").desc) diff --git a/Keychain/Sources/Keychain/Extensions/NSError+Extensions.swift b/Keychain/Sources/Keychain/Extensions/NSError+Extensions.swift index 0ffe8aa..fa4d5b3 100644 --- a/Keychain/Sources/Keychain/Extensions/NSError+Extensions.swift +++ b/Keychain/Sources/Keychain/Extensions/NSError+Extensions.swift @@ -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) { diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 5158511..7035d10 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -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; }; diff --git a/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e3ec4b1..90d645d 100644 --- a/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Metatext.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 } }, { diff --git a/Secrets/Sources/Secrets/Secrets.swift b/Secrets/Sources/Secrets/Secrets.swift index 286dd6d..87e38e9 100644 --- a/Secrets/Sources/Secrets/Secrets.swift +++ b/Secrets/Sources/Secrets/Secrets.swift @@ -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(_ 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(_ 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 } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift index d2cc953..0b6fb1e 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift @@ -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() diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 2a23b35..6662414 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -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 in self?.observationErrorsInput.send(error)