Modularize Keychain and Secrets

This commit is contained in:
Justin Mazzocchi 2020-09-03 17:54:05 -07:00
parent 06b84a0aa7
commit f6f065e143
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
18 changed files with 173 additions and 62 deletions

5
Keychain/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

31
Keychain/Package.swift Normal file
View file

@ -0,0 +1,31 @@
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "Keychain",
platforms: [
.iOS(.v14),
.macOS(.v11)
],
products: [
.library(
name: "Keychain",
targets: ["Keychain"]),
.library(
name: "MockKeychain",
targets: ["MockKeychain"])
],
dependencies: [],
targets: [
.target(
name: "Keychain",
dependencies: []),
.target(
name: "MockKeychain",
dependencies: ["Keychain"]),
.testTarget(
name: "KeychainTests",
dependencies: ["MockKeychain"])
]
)

View file

@ -2,7 +2,7 @@
import Foundation
public protocol KeychainService {
public protocol Keychain {
static func setGenericPassword(data: Data, forAccount key: String, service: String) throws
static func deleteGenericPassword(account: String, service: String) throws
static func getGenericPassword(account: String, service: String) throws -> Data?
@ -11,9 +11,9 @@ public protocol KeychainService {
static func deleteKey(applicationTag: String) throws
}
public struct LiveKeychainService {}
public struct LiveKeychain {}
extension LiveKeychainService: KeychainService {
extension LiveKeychain: Keychain {
public static func setGenericPassword(data: Data, forAccount account: String, service: String) throws {
var query = genericPasswordQueryDictionary(account: account, service: service)
@ -115,7 +115,7 @@ extension LiveKeychainService: KeychainService {
}
}
private extension LiveKeychainService {
private extension LiveKeychain {
static func genericPasswordQueryDictionary(account: String, service: String) -> [String: Any] {
[kSecAttrService as String: service,
kSecAttrAccount as String: account,

View file

@ -1,17 +1,17 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import ServiceLayer
import Keychain
public struct MockKeychainService {}
public struct MockKeychain {}
public extension MockKeychainService {
public extension MockKeychain {
static func reset() {
items = [String: Data]()
}
}
extension MockKeychainService: KeychainService {
extension MockKeychain: Keychain {
public static func setGenericPassword(data: Data, forAccount key: String, service: String) throws {
items[key] = data
}
@ -37,6 +37,6 @@ extension MockKeychainService: KeychainService {
}
}
private extension MockKeychainService {
private extension MockKeychain {
static var items = [String: Data]()
}

View file

@ -0,0 +1,10 @@
import XCTest
@testable import Keychain
final class KeychainTests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
}
}

View file

@ -7,18 +7,19 @@
objects = {
/* Begin PBXBuildFile section */
D0175CAC24FE2D6300B085F6 /* PreviewViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0175CAB24FE2D6300B085F6 /* PreviewViewModels */; };
D01F41D724F880C400D55A2D /* StatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D01F41D424F880C400D55A2D /* StatusTableViewCell.xib */; };
D01F41D824F880C400D55A2D /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D524F880C400D55A2D /* StatusTableViewCell.swift */; };
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
D01F41E424F8889700D55A2D /* AttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* AttachmentsView.swift */; };
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
D0BDF66B24FD7CEC00C7FA1C /* ServiceLayer in Frameworks */ = {isa = PBXBuildFile; productRef = D0BDF66A24FD7CEC00C7FA1C /* ServiceLayer */; };
D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */; };
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1F624F9A84B001B0F04 /* LoadingTableFooterView.swift */; };
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */; };
D0BEB20524FA1107001B0F04 /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB20424FA1107001B0F04 /* FiltersView.swift */; };
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 */; };
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 */; };
@ -92,6 +93,8 @@
D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsView.swift; sourceTree = "<group>"; };
D0BEB20424FA1107001B0F04 /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterView.swift; sourceTree = "<group>"; };
D0BECB952501B3DD002C1B13 /* Keychain */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Keychain; sourceTree = "<group>"; };
D0BECB962501BCE0002C1B13 /* Secrets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Secrets; sourceTree = "<group>"; };
D0BFDAF524FC7C5300C86618 /* HTTP */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HTTP; sourceTree = "<group>"; };
D0C7D41E24F76169001EBDBB /* Metatext.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; };
D0C7D41F24F76169001EBDBB /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -132,7 +135,7 @@
files = (
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */,
D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */,
D0175CAC24FE2D6300B085F6 /* PreviewViewModels in Frameworks */,
D0BECB9C2501C731002C1B13 /* PreviewViewModels in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -147,7 +150,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D0BDF66B24FD7CEC00C7FA1C /* ServiceLayer in Frameworks */,
D0BECB982501C0FC002C1B13 /* Secrets in Frameworks */,
D0BECB9A2501C15F002C1B13 /* Mastodon in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -181,10 +185,12 @@
D0C7D46824F76169001EBDBB /* Extensions */,
D0666A7924C7745A00F3F04B /* Frameworks */,
D0BFDAF524FC7C5300C86618 /* HTTP */,
D0BECB952501B3DD002C1B13 /* Keychain */,
D0C7D45624F76169001EBDBB /* Localizations */,
D0E0F1E424FC49FC002C04BF /* Mastodon */,
D0E5361A24E3EB4D00FB1CE1 /* Notification Service Extension */,
D047FA8D24C3E21200AF17C5 /* Products */,
D0BECB962501BCE0002C1B13 /* Secrets */,
D0BDF66524FD7A6400C7FA1C /* ServiceLayer */,
D0C7D41D24F76169001EBDBB /* Supporting Files */,
D0C7D45324F76169001EBDBB /* System */,
@ -323,7 +329,7 @@
packageProductDependencies = (
D06B492224D4611300642749 /* KingfisherSwiftUI */,
D0E2C1D024FD97F000854680 /* ViewModels */,
D0175CAB24FE2D6300B085F6 /* PreviewViewModels */,
D0BECB9B2501C731002C1B13 /* PreviewViewModels */,
);
productName = "Metatext (iOS)";
productReference = D047FA8C24C3E21200AF17C5 /* Metatext.app */;
@ -363,7 +369,8 @@
);
name = "Notification Service Extension";
packageProductDependencies = (
D0BDF66A24FD7CEC00C7FA1C /* ServiceLayer */,
D0BECB972501C0FC002C1B13 /* Secrets */,
D0BECB992501C15F002C1B13 /* Mastodon */,
);
productName = "Notification Service Extension";
productReference = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */;
@ -841,18 +848,22 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D0175CAB24FE2D6300B085F6 /* PreviewViewModels */ = {
isa = XCSwiftPackageProductDependency;
productName = PreviewViewModels;
};
D06B492224D4611300642749 /* KingfisherSwiftUI */ = {
isa = XCSwiftPackageProductDependency;
package = D06B492124D4611300642749 /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = KingfisherSwiftUI;
};
D0BDF66A24FD7CEC00C7FA1C /* ServiceLayer */ = {
D0BECB972501C0FC002C1B13 /* Secrets */ = {
isa = XCSwiftPackageProductDependency;
productName = ServiceLayer;
productName = Secrets;
};
D0BECB992501C15F002C1B13 /* Mastodon */ = {
isa = XCSwiftPackageProductDependency;
productName = Mastodon;
};
D0BECB9B2501C731002C1B13 /* PreviewViewModels */ = {
isa = XCSwiftPackageProductDependency;
productName = PreviewViewModels;
};
D0E2C1D024FD97F000854680 /* ViewModels */ = {
isa = XCSwiftPackageProductDependency;

View file

@ -2,8 +2,9 @@
import UserNotifications
import CryptoKit
import Keychain
import Mastodon
import ServiceLayer
import Secrets
class NotificationService: UNNotificationServiceExtension {
@ -91,7 +92,7 @@ private extension NotificationService {
let serverPublicKeyData = Data(base64Encoded: serverPublicKeyBase64)
else { throw NotificationServiceError.userInfoDataAbsent }
let secretsService = SecretsService(identityID: identityID, keychainService: LiveKeychainService.self)
let secretsService = Secrets(identityID: identityID, keychain: LiveKeychain.self)
guard
let auth = try secretsService.getPushAuth(),

5
Secrets/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

27
Secrets/Package.swift Normal file
View file

@ -0,0 +1,27 @@
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "Secrets",
platforms: [
.iOS(.v14),
.macOS(.v11)
],
products: [
.library(
name: "Secrets",
targets: ["Secrets"])
],
dependencies: [
.package(path: "Keychain")
],
targets: [
.target(
name: "Secrets",
dependencies: ["Keychain"]),
.testTarget(
name: "SecretsTests",
dependencies: ["Secrets"])
]
)

View file

@ -1,6 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
import Keychain
public protocol SecretsStorable {
var dataStoredInSecrets: Data { get }
@ -11,17 +12,17 @@ enum SecretsStorableError: Error {
case conversionFromDataStoredInSecrets(Data)
}
public struct SecretsService {
public struct Secrets {
public let identityID: UUID
private let keychainService: KeychainService.Type
private let keychain: Keychain.Type
public init(identityID: UUID, keychainService: KeychainService.Type) {
public init(identityID: UUID, keychain: Keychain.Type) {
self.identityID = identityID
self.keychainService = keychainService
self.keychain = keychain
}
}
public extension SecretsService {
public extension Secrets {
enum Item: String, CaseIterable {
case clientID
case clientSecret
@ -35,7 +36,7 @@ enum SecretsServiceError: Error {
case itemAbsent
}
extension SecretsService.Item {
extension Secrets.Item {
enum Kind {
case genericPassword
case key
@ -49,16 +50,16 @@ extension SecretsService.Item {
}
}
public extension SecretsService {
public extension Secrets {
func set(_ data: SecretsStorable, forItem item: Item) throws {
try keychainService.setGenericPassword(
try keychain.setGenericPassword(
data: data.dataStoredInSecrets,
forAccount: key(item: item),
service: Self.keychainServiceName)
}
func item<T: SecretsStorable>(_ item: Item) throws -> T {
guard let data = try keychainService.getGenericPassword(
guard let data = try keychain.getGenericPassword(
account: key(item: item),
service: Self.keychainServiceName) else {
throw SecretsServiceError.itemAbsent
@ -68,26 +69,26 @@ public extension SecretsService {
}
func deleteAllItems() throws {
for item in SecretsService.Item.allCases {
for item in Secrets.Item.allCases {
switch item.kind {
case .genericPassword:
try keychainService.deleteGenericPassword(
try keychain.deleteGenericPassword(
account: key(item: item),
service: Self.keychainServiceName)
case .key:
try keychainService.deleteKey(applicationTag: key(item: item))
try keychain.deleteKey(applicationTag: key(item: item))
}
}
}
func generatePushKeyAndReturnPublicKey() throws -> Data {
try keychainService.generateKeyAndReturnPublicKey(
try keychain.generateKeyAndReturnPublicKey(
applicationTag: key(item: .pushKey),
attributes: PushKey.attributes)
}
func getPushKey() throws -> Data? {
try keychainService.getPrivateKey(
try keychain.getPrivateKey(
applicationTag: key(item: .pushKey),
attributes: PushKey.attributes)
}
@ -109,7 +110,7 @@ public extension SecretsService {
}
}
private extension SecretsService {
private extension Secrets {
static let keychainServiceName = "com.metabolist.metatext"
func key(item: Item) -> String {

View file

@ -0,0 +1,10 @@
import XCTest
@testable import Secrets
final class SecretsTests: XCTestCase {
func testExample() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct
// results.
}
}

View file

@ -19,15 +19,20 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/groue/CombineExpectations.git", .upToNextMajor(from: "0.5.0")),
.package(path: "DB"),
.package(path: "Mastodon")
.package(path: "Keychain"),
.package(path: "Mastodon"),
.package(path: "Secrets")
],
targets: [
.target(
name: "ServiceLayer",
dependencies: ["DB"]),
dependencies: ["DB", "Secrets"]),
.target(
name: "ServiceLayerMocks",
dependencies: ["ServiceLayer", .product(name: "MastodonStubs", package: "Mastodon")]),
dependencies: [
"ServiceLayer",
.product(name: "MastodonStubs", package: "Mastodon"),
.product(name: "MockKeychain", package: "Keychain")]),
.testTarget(
name: "ServiceLayerTests",
dependencies: ["CombineExpectations", "ServiceLayerMocks"])

View file

@ -4,6 +4,7 @@ import DB
import Foundation
import Combine
import Mastodon
import Secrets
public struct AllIdentitiesService {
public let mostRecentlyUsedIdentityID: AnyPublisher<UUID?, Never>
@ -34,24 +35,24 @@ public extension AllIdentitiesService {
}
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Never, Error> {
let secretsService = SecretsService(identityID: id, keychainService: environment.keychainServiceType)
let secrets = Secrets(identityID: id, keychain: environment.keychain)
let authenticationService = AuthenticationService(environment: environment)
return authenticationService.authorizeApp(instanceURL: instanceURL)
.tryMap { appAuthorization -> (URL, AppAuthorization) in
try secretsService.set(appAuthorization.clientId, forItem: .clientID)
try secretsService.set(appAuthorization.clientSecret, forItem: .clientSecret)
try secrets.set(appAuthorization.clientId, forItem: .clientID)
try secrets.set(appAuthorization.clientSecret, forItem: .clientSecret)
return (instanceURL, appAuthorization)
}
.flatMap(authenticationService.authenticate(instanceURL:appAuthorization:))
.tryMap { try secretsService.set($0.accessToken, forItem: .accessToken) }
.tryMap { try secrets.set($0.accessToken, forItem: .accessToken) }
.ignoreOutput()
.eraseToAnyPublisher()
}
func deleteIdentity(_ identity: Identity) -> AnyPublisher<Never, Error> {
let secretsService = SecretsService(identityID: identity.id, keychainService: environment.keychainServiceType)
let secrets = Secrets(identityID: identity.id, keychain: environment.keychain)
let networkClient = APIClient(session: environment.session)
networkClient.instanceURL = identity.url
@ -60,14 +61,14 @@ public extension AllIdentitiesService {
.collect()
.tryMap { _ in
DeletionEndpoint.oauthRevoke(
token: try secretsService.item(.accessToken),
clientID: try secretsService.item(.clientID),
clientSecret: try secretsService.item(.clientSecret))
token: try secrets.item(.accessToken),
clientID: try secrets.item(.clientID),
clientSecret: try secrets.item(.clientSecret))
}
.flatMap(networkClient.request)
.collect()
.tryMap { _ in
try secretsService.deleteAllItems()
try secrets.deleteAllItems()
try ContentDatabase.delete(forIdentityID: identity.id)
}
.ignoreOutput()

View file

@ -3,13 +3,14 @@
import DB
import Foundation
import HTTP
import Keychain
import Mastodon
import UserNotifications
public struct AppEnvironment {
let session: Session
let webAuthSessionType: WebAuthSession.Type
let keychainServiceType: KeychainService.Type
let keychain: Keychain.Type
let userDefaults: UserDefaults
let userNotificationClient: UserNotificationClient
let inMemoryContent: Bool
@ -17,14 +18,14 @@ public struct AppEnvironment {
public init(session: Session,
webAuthSessionType: WebAuthSession.Type,
keychainServiceType: KeychainService.Type,
keychain: Keychain.Type,
userDefaults: UserDefaults,
userNotificationClient: UserNotificationClient,
inMemoryContent: Bool,
identityFixture: IdentityFixture?) {
self.session = session
self.webAuthSessionType = webAuthSessionType
self.keychainServiceType = keychainServiceType
self.keychain = keychain
self.userDefaults = userDefaults
self.userNotificationClient = userNotificationClient
self.inMemoryContent = inMemoryContent
@ -37,7 +38,7 @@ public extension AppEnvironment {
Self(
session: Session(configuration: .default),
webAuthSessionType: LiveWebAuthSession.self,
keychainServiceType: LiveKeychainService.self,
keychain: LiveKeychain.self,
userDefaults: .standard,
userNotificationClient: .live(userNotificationCenter),
inMemoryContent: false,

View file

@ -4,6 +4,7 @@ import DB
import Foundation
import Combine
import Mastodon
import Secrets
public class IdentityService {
@Published public private(set) var identity: Identity
@ -13,7 +14,7 @@ public class IdentityService {
private let contentDatabase: ContentDatabase
private let environment: AppEnvironment
private let networkClient: APIClient
private let secretsService: SecretsService
private let secrets: Secrets
private let observationErrorsInput = PassthroughSubject<Error, Never>()
init(identityID: UUID,
@ -33,12 +34,12 @@ public class IdentityService {
guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound }
self.identity = identity
secretsService = SecretsService(
secrets = Secrets(
identityID: identityID,
keychainService: environment.keychainServiceType)
keychain: environment.keychain)
networkClient = APIClient(session: environment.session)
networkClient.instanceURL = identity.url
networkClient.accessToken = try? secretsService.item(.accessToken)
networkClient.accessToken = try? secrets.item(.accessToken)
contentDatabase = try ContentDatabase(identityID: identityID, inMemory: environment.inMemoryContent)
@ -168,8 +169,8 @@ public extension IdentityService {
let auth: String
do {
publicKey = try secretsService.generatePushKeyAndReturnPublicKey().base64EncodedString()
auth = try secretsService.generatePushAuth().base64EncodedString()
publicKey = try secrets.generatePushKeyAndReturnPublicKey().base64EncodedString()
auth = try secrets.generatePushAuth().base64EncodedString()
} catch {
return Fail(error: error).eraseToAnyPublisher()
}

View file

@ -3,6 +3,7 @@
import DB
import Foundation
import HTTP
import MockKeychain
import ServiceLayer
import Stubbing
@ -11,7 +12,7 @@ public extension AppEnvironment {
AppEnvironment(
session: Session(configuration: .stubbing),
webAuthSessionType: SuccessfulMockWebAuthSession.self,
keychainServiceType: MockKeychainService.self,
keychain: MockKeychain.self,
userDefaults: MockUserDefaults(),
userNotificationClient: .mock,
inMemoryContent: true,

View file

@ -5,6 +5,7 @@ import Combine
import CombineExpectations
import HTTP
import Mastodon
import MockKeychain
import ServiceLayer
import ServiceLayerMocks
@testable import ViewModels
@ -48,7 +49,7 @@ class AddIdentityViewModelTests: XCTestCase {
let environment = AppEnvironment(
session: Session(configuration: .stubbing),
webAuthSessionType: CanceledLoginMockWebAuthSession.self,
keychainServiceType: MockKeychainService.self,
keychain: MockKeychain.self,
userDefaults: MockUserDefaults(),
userNotificationClient: .mock,
inMemoryContent: true,