From 366d30a00db32f89ea64e702ce9b3139474ee825 Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Fri, 11 Mar 2022 15:51:46 +0100 Subject: [PATCH] KeyStoring protocol implemented The RecoveryPhraseStorage is an implementation of KeyStoring protocol. The unit tests are testing the keychain functionality to ensure security and proper error handling. Refactor All comments from the PR have been incorporated Another batch of comment updates thx Adam! alphabetical order refactored to private extension Update secant/Util/RecoveryPhraseStorage.swift Update secant/Util/RecoveryPhraseStorage.swift cleanup refactor and cleanup setData refactor Updated to the latest requirements The KeyStoring implementation now relies on JSON (encoded) storing version, seed phrase and birthday. method separation fixed typo functions documented typo and warnings fixed Mock services removed the file and the content is not used anywhere, once we will need some mocks, we will implement it RecoveryPhraseStorage singleton removed Language added to the storage Co-Authored-By: Adam --- secant.xcodeproj/project.pbxproj | 20 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- secant/MockedDependencies/KeyStoring.swift | 42 ++- secant/Util/RecoveryPhraseStorage.swift | 254 +++++++++++++++ .../Util/RecoveryPhraseStorageTests.swift | 295 ++++++++++++++++++ 5 files changed, 596 insertions(+), 19 deletions(-) create mode 100644 secant/Util/RecoveryPhraseStorage.swift create mode 100644 secantTests/Util/RecoveryPhraseStorageTests.swift diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index a4eb809..6c1da61 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 0D185819272723FF0046B928 /* ColoredChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D185818272723FF0046B928 /* ColoredChip.swift */; }; 0D18581B272728D60046B928 /* PhraseChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D18581A272728D60046B928 /* PhraseChip.swift */; }; 0D1922F226BDE29300052649 /* ZcashSDKStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */; }; - 0D1922F826BDEB3500052649 /* MockServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D1922F726BDEB3500052649 /* MockServices.swift */; }; 0D1C1AA327611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D1C1AA227611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift */; }; 0D2ACE8026C2C67100D62E3C /* Zboto.otf in Resources */ = {isa = PBXBuildFile; fileRef = 0D2ACE7F26C2C67100D62E3C /* Zboto.otf */; }; 0D354A0926D5A9D000315F45 /* Services.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D354A0626D5A9D000315F45 /* Services.swift */; }; @@ -82,6 +81,8 @@ 66DC733F271D88CC0053CBB6 /* StandardButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66DC733E271D88CC0053CBB6 /* StandardButtonStyle.swift */; }; 9E2AC0FF27D8EC120042AA47 /* MnemonicSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9E2AC0FE27D8EC120042AA47 /* MnemonicSwift */; }; 9E2AC10127D8EF0B0042AA47 /* MnemonicSeedPhraseProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2AC10027D8EF0B0042AA47 /* MnemonicSeedPhraseProvider.swift */; }; + 9E2AC10327DA28200042AA47 /* RecoveryPhraseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2AC10227DA28200042AA47 /* RecoveryPhraseStorage.swift */; }; + 9E2AC10627DA34610042AA47 /* RecoveryPhraseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2AC10527DA34610042AA47 /* RecoveryPhraseStorageTests.swift */; }; 9E2DF99C27CF704D00649636 /* ImportWalletStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2DF99827CF704D00649636 /* ImportWalletStore.swift */; }; 9E2DF99D27CF704D00649636 /* ImportSeedEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2DF99A27CF704D00649636 /* ImportSeedEditor.swift */; }; 9E2DF99E27CF704D00649636 /* ImportWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2DF99B27CF704D00649636 /* ImportWalletView.swift */; }; @@ -141,7 +142,6 @@ 0D185818272723FF0046B928 /* ColoredChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColoredChip.swift; sourceTree = ""; }; 0D18581A272728D60046B928 /* PhraseChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhraseChip.swift; sourceTree = ""; }; 0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZcashSDKStubs.swift; sourceTree = ""; }; - 0D1922F726BDEB3500052649 /* MockServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServices.swift; sourceTree = ""; }; 0D1C1AA227611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseDisplayReducerTests.swift; sourceTree = ""; }; 0D2ACE7F26C2C67100D62E3C /* Zboto.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Zboto.otf; sourceTree = ""; }; 0D354A0626D5A9D000315F45 /* Services.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = ""; }; @@ -215,6 +215,8 @@ 66D50667271D9B6100E51F0D /* NavigationButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationButtonStyle.swift; sourceTree = ""; }; 66DC733E271D88CC0053CBB6 /* StandardButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardButtonStyle.swift; sourceTree = ""; }; 9E2AC10027D8EF0B0042AA47 /* MnemonicSeedPhraseProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicSeedPhraseProvider.swift; sourceTree = ""; }; + 9E2AC10227DA28200042AA47 /* RecoveryPhraseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseStorage.swift; sourceTree = ""; }; + 9E2AC10527DA34610042AA47 /* RecoveryPhraseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseStorageTests.swift; sourceTree = ""; }; 9E2DF99827CF704D00649636 /* ImportWalletStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportWalletStore.swift; sourceTree = ""; }; 9E2DF99A27CF704D00649636 /* ImportSeedEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportSeedEditor.swift; sourceTree = ""; }; 9E2DF99B27CF704D00649636 /* ImportWalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportWalletView.swift; sourceTree = ""; }; @@ -315,7 +317,6 @@ isa = PBXGroup; children = ( 0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */, - 0D1922F726BDEB3500052649 /* MockServices.swift */, ); path = Stubs; sourceTree = ""; @@ -410,6 +411,7 @@ 0D4E7A1926B364180058B01E /* secantTests */ = { isa = PBXGroup; children = ( + 9E2AC10427DA34450042AA47 /* Util */, 0DFE93E4272CB6D0000FCCA5 /* RecoveryPhraseValidationTests */, 0DFE93DD272C6D4B000FCCA5 /* BackupFlowTests */, 6654C7422715A48E00901167 /* OnboardingTests */, @@ -501,6 +503,7 @@ F93673D52742CB840099C6AF /* Previews.swift */, 0D35CC45277A36E00074316A /* ScrollableWhenScaled.swift */, 9E2AC10027D8EF0B0042AA47 /* MnemonicSeedPhraseProvider.swift */, + 9E2AC10227DA28200042AA47 /* RecoveryPhraseStorage.swift */, ); path = Util; sourceTree = ""; @@ -654,6 +657,14 @@ path = CircularFrame; sourceTree = ""; }; + 9E2AC10427DA34450042AA47 /* Util */ = { + isa = PBXGroup; + children = ( + 9E2AC10527DA34610042AA47 /* RecoveryPhraseStorageTests.swift */, + ); + path = Util; + sourceTree = ""; + }; 9E2DF99727CF704D00649636 /* ImportWallet */ = { isa = PBXGroup; children = ( @@ -1079,7 +1090,6 @@ 0DA13CA526C1963000E3B610 /* Balance.swift in Sources */, 2EA11F5B27467EF800709571 /* OnboardingFooterView.swift in Sources */, 66D50668271D9B6100E51F0D /* NavigationButtonStyle.swift in Sources */, - 0D1922F826BDEB3500052649 /* MockServices.swift in Sources */, 0D3D040A2728B3A10032ABC1 /* RecoveryPhraseDisplayStore.swift in Sources */, 9E2AC10127D8EF0B0042AA47 /* MnemonicSeedPhraseProvider.swift in Sources */, 0D4E7A0B26B364170058B01E /* ContentView.swift in Sources */, @@ -1096,6 +1106,7 @@ 663FAB9C271D874D00E495F8 /* ActiveButton.swift in Sources */, F9C165C02740403600592F76 /* ApproveView.swift in Sources */, 0DF2DC5427235E3E00FA31E2 /* View+InnerShadow.swift in Sources */, + 9E2AC10327DA28200042AA47 /* RecoveryPhraseStorage.swift in Sources */, F9971A6B27680E1000A2DB75 /* WalletInfo.swift in Sources */, 0D185819272723FF0046B928 /* ColoredChip.swift in Sources */, 2EA11F5D27467F7700709571 /* OnboardingContentView.swift in Sources */, @@ -1129,6 +1140,7 @@ 0DFE93DF272C6D4B000FCCA5 /* RecoveryPhraseBackupTests.swift in Sources */, 6654C7442715A4AC00901167 /* OnboardingStoreTests.swift in Sources */, 0D1C1AA327611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift in Sources */, + 9E2AC10627DA34610042AA47 /* RecoveryPhraseStorageTests.swift in Sources */, 0D4E7A1B26B364180058B01E /* secantTests.swift in Sources */, 0DFE93E6272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift in Sources */, ); diff --git a/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 423339e..299d5de 100644 --- a/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/secant.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/zcash-hackworks/MnemonicSwift", "state": { "branch": null, - "revision": "27711179a75a1172d6f04ceb5d86419cf0cba401", - "version": "2.1.0" + "revision": "b10b0b8ee1f297e33ea5b1bc041ced49943b6582", + "version": "2.2.3" } }, { diff --git a/secant/MockedDependencies/KeyStoring.swift b/secant/MockedDependencies/KeyStoring.swift index 3c06160..0409b87 100644 --- a/secant/MockedDependencies/KeyStoring.swift +++ b/secant/MockedDependencies/KeyStoring.swift @@ -6,33 +6,49 @@ // import Foundation +import MnemonicSwift + +/// Representation of the wallet stored in the persistent storage (typically keychain, handled by `RecoveryPhraseStorage`). +struct StoredWallet: Codable, Equatable { + var birthday: BlockHeight? + let language: MnemonicLanguageType + let seedPhrase: String + let version: Int +} protocol KeyStoring { - func importBirthday(_ height: BlockHeight) throws - func exportBirthday() throws -> BlockHeight - func importPhrase(bip39 phrase: String) throws - func exportPhrase() throws -> String + /** + Store recovery phrase and optionally even birthday to the secured and persistent storage. + This function creates an instance of `StoredWallet` and automatically handles versioning of the stored data. + */ + func importRecoveryPhrase(bip39 phrase: String, birthday: BlockHeight?, language: MnemonicLanguageType) throws + + /** + Load the representation of the wallet from the persistent and secured storage. + */ + func exportWallet() throws -> StoredWallet + + /** + Check if the wallet representation `StoredWallet` is present in the persistent storage. + */ func areKeysPresent() throws -> Bool - /** - Use carefully: Deletes the seed phrase from the keychain - */ - func nukePhrase() /** - Use carefully: deletes the wallet birthday from the keychain + Update the birthday in the securely stored wallet. */ - func nukeBirthday() + func updateBirthday(_ height: BlockHeight) throws /** - There's no fate but what we make for ourselves - Sarah Connor + Use carefully: deletes the stored wallet. + There's no fate but what we make for ourselves - Sarah Connor. */ func nukeWallet() - - var keysPresent: Bool { get } } enum KeyStoringError: Error { case alreadyImported case uninitializedWallet case storageError(Error) + case unsupportedVersion(Int) + case unsupportedLanguage(MnemonicLanguageType) } diff --git a/secant/Util/RecoveryPhraseStorage.swift b/secant/Util/RecoveryPhraseStorage.swift new file mode 100644 index 0000000..91ff8e8 --- /dev/null +++ b/secant/Util/RecoveryPhraseStorage.swift @@ -0,0 +1,254 @@ +// +// RecoveryPhraseStorage.swift +// secant-testnet +// +// Created by Lukáš Korba on 03/10/2022. +// + +import Foundation +import MnemonicSwift + +/// Zcash implementation of the keychain that is not universal but designed to deliver functionality needed by the wallet itself. +/// All the APIs should be thread safe according to official doc: +/// https://developer.apple.com/documentation/security/certificate_key_and_trust_services/working_with_concurrency?language=objc +// swiftlint:disable convenience_type +final class RecoveryPhraseStorage { + enum Constants { + static let zcashStoredWallet = "zcashStoredWallet" + /// Versioning of the stored data + static let zcashKeychainVersion = 1 + } + + enum KeychainError: Error, Equatable { + case decoding + case duplicate + case encoding + case noDataFound + case unknown(OSStatus) + case unsupportedVersion(Int) + case unsupportedLanguage(MnemonicLanguageType) + } + + // MARK: - Codable helpers + + static func decode(json: Data, as clazz: T.Type) throws -> T? { + do { + let decoder = JSONDecoder() + let data = try decoder.decode(T.self, from: json) + return data + } catch { + throw KeychainError.decoding + } + } + + static func encode(object: T) throws -> Data? { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + return try encoder.encode(object) + } catch { + throw KeychainError.encoding + } + } + + // MARK: - Query Helpers + + static func baseQuery(forAccount account: String = "", andKey forKey: String) -> [String: Any] { + let query:[ String: AnyObject ] = [ + /// Uniquely identify this keychain accessor + kSecAttrService as String: forKey as AnyObject, + kSecAttrAccount as String: account as AnyObject, + kSecClass as String: kSecClassGenericPassword, + /// The data in the keychain item can be accessed only while the device is unlocked by the user. + /// This is recommended for items that need to be accessible only while the application is in the foreground. + /// Items with this attribute do not migrate to a new device. + /// Thus, after restoring from a backup of a different device, these items will not be present. + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + return query + } + + static func restoreQuery(forAccount account: String = "", andKey forKey: String) -> [String: Any] { + var query = baseQuery(forAccount: account, andKey: forKey) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecReturnRef as String] = kCFBooleanFalse + query[kSecReturnPersistentRef as String] = kCFBooleanFalse + query[kSecReturnAttributes as String] = kCFBooleanFalse + + return query + } +} + +// MARK: - Recovery Phrase Helper Functions + +private extension RecoveryPhraseStorage { + func setWallet(_ wallet: StoredWallet) throws { + guard let data = try RecoveryPhraseStorage.encode(object: wallet) else { + throw KeychainError.encoding + } + + try setData(data, forKey: Constants.zcashStoredWallet) + } + + func wallet() throws -> StoredWallet { + guard let data = data(forKey: Constants.zcashStoredWallet) else { + throw KeychainError.noDataFound + } + + guard let wallet = try RecoveryPhraseStorage.decode(json: data, as: StoredWallet.self) else { + throw KeychainError.decoding + } + + guard wallet.version == Constants.zcashKeychainVersion else { + throw KeychainError.unsupportedVersion(wallet.version) + } + + return wallet + } + + /// Use carefully: Deletes seed phrase from the keychain!!! + @discardableResult + func deleteWallet() -> Bool { + deleteData(forKey: Constants.zcashStoredWallet) + } + + /// Restore data for key + func data( + forKey: String, + account: String = "" + ) -> Data? { + let query = RecoveryPhraseStorage.restoreQuery(forAccount: account, andKey: forKey) + + var result: AnyObject? + _ = SecItemCopyMatching(query as CFDictionary, &result) + + return result as? Data + } + + /// Use carefully: Deletes data for key + func deleteData( + forKey: String, + account: String = "" + ) -> Bool { + let query = RecoveryPhraseStorage.baseQuery(forAccount: account, andKey: forKey) + + let status = SecItemDelete(query as CFDictionary) + + return status == noErr + } + + /// Store data for key + func setData( + _ data: Data, + forKey: String, + account: String = "" + ) throws { + var query = RecoveryPhraseStorage.baseQuery(forAccount: account, andKey: forKey) + query[kSecValueData as String] = data as AnyObject + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status != errSecDuplicateItem else { + throw KeychainError.duplicate + } + + guard status == errSecSuccess else { + throw KeychainError.unknown(status) + } + } + + /// Use carefully: Update data for key + func updateData( + _ data: Data, + forKey: String, + account: String = "" + ) throws { + let query = RecoveryPhraseStorage.baseQuery(forAccount: account, andKey: forKey) + + let attributes:[ String: AnyObject ] = [ + kSecValueData as String: data as AnyObject + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + + guard status != errSecItemNotFound else { + throw KeychainError.noDataFound + } + + guard status == errSecSuccess else { + throw KeychainError.unknown(status) + } + } +} + +// MARK: - KeyStoring + +extension RecoveryPhraseStorage: KeyStoring { + func importRecoveryPhrase(bip39 phrase: String, birthday: BlockHeight?, language: MnemonicLanguageType = .english) throws { + // Future-proof of the bundle to potentialy avoid migration. We enforce english mnemonic. + guard language == .english else { + throw KeyStoringError.unsupportedLanguage(language) + } + + do { + let wallet = StoredWallet( + birthday: birthday, + language: language, + seedPhrase: phrase, + version: Constants.zcashKeychainVersion + ) + + try setWallet(wallet) + } catch KeychainError.duplicate { + throw KeyStoringError.alreadyImported + } catch { + throw KeyStoringError.storageError(error) + } + } + + func exportWallet() throws -> StoredWallet { + do { + return try wallet() + } catch KeychainError.noDataFound, KeychainError.decoding { + throw KeyStoringError.uninitializedWallet + } catch KeychainError.unsupportedVersion(let version) { + throw KeyStoringError.unsupportedVersion(version) + } catch { + throw KeyStoringError.storageError(error) + } + } + + func areKeysPresent() throws -> Bool { + do { + _ = try exportWallet() + } catch KeyStoringError.uninitializedWallet { + return false + } catch { + // TODO: - report & log error.localizedDescription + return false + } + + return true + } + + func updateBirthday(_ height: BlockHeight) throws { + do { + var wallet = try exportWallet() + wallet.birthday = height + + guard let data = try RecoveryPhraseStorage.encode(object: wallet) else { + throw KeychainError.encoding + } + + try updateData(data, forKey: Constants.zcashStoredWallet) + } catch { + throw error + } + } + + func nukeWallet() { + deleteWallet() + } +} diff --git a/secantTests/Util/RecoveryPhraseStorageTests.swift b/secantTests/Util/RecoveryPhraseStorageTests.swift new file mode 100644 index 0000000..d8d164f --- /dev/null +++ b/secantTests/Util/RecoveryPhraseStorageTests.swift @@ -0,0 +1,295 @@ +// +// RecoveryPhraseStorageTests.swift +// secantTests +// +// Created by Lukáš Korba on 10.03.2022. +// + +import XCTest +import MnemonicSwift +@testable import secant_testnet + +extension KeyStoringError { + var debugValue: String { + switch self { + case .alreadyImported: return "alreadyImported" + case .uninitializedWallet: return "uninitializedWallet" + case .storageError: return "storageError" + case .unsupportedVersion: return "unsupportedVersion" + case .unsupportedLanguage: return "unsupportedLanguage" + } + } +} + +class RecoveryPhraseStorageTests: XCTestCase { + let birthday = BlockHeight(12345678) + let seedPhrase = "one two three" + let language = MnemonicLanguageType.english + var storage: RecoveryPhraseStorage? + + override func setUp() { + super.setUp() + storage = RecoveryPhraseStorage() + deleteData(forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) + } + + override func tearDown() { + super.tearDown() + storage = nil + } + + func testWalletStoredSucessfuly() throws { + do { + try storage?.importRecoveryPhrase(bip39: seedPhrase, birthday: birthday) + guard let data = data(forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) else { + return XCTFail("Keychain: no data found for key: `zcashStoredWallet`.") + } + + guard let walletReceived = try RecoveryPhraseStorage.decode(json: data, as: StoredWallet.self) else { + return XCTFail("Keychain: `walletReceived` can't be decoded.") + } + + XCTAssertEqual(birthday, walletReceived.birthday, "Keychain: stored birthday and retrieved one must be the same.") + XCTAssertEqual(seedPhrase, walletReceived.seedPhrase, "Keychain: stored seed phrase and retrieved one must be the same.") + } catch let err { + XCTFail("Keychain: no error is expected for `testWalletStoredSucessfuly` but received. \(err)") + } + } + + func testWalletDuplicate() throws { + do { + try storage?.importRecoveryPhrase(bip39: seedPhrase, birthday: birthday) + try storage?.importRecoveryPhrase(bip39: seedPhrase, birthday: birthday) + + XCTFail("Keychain: `testRecoveryPhraseDuplicate` is expected to throw a `duplicate` error but passed instead.") + } catch { + guard let error = error as? KeyStoringError else { + XCTFail("Keychain: the error is expected to be KeyStoringError but it's \(error).") + + return + } + + XCTAssertEqual( + error.debugValue, + KeyStoringError.alreadyImported.debugValue, + "Keychain: error must be .alreadyImported but it's \(error)." + ) + } + } + + func testUninitializedWallet() throws { + do { + _ = try storage?.exportWallet() + + XCTFail("Keychain: `testUninitializedWallet` should fail but received some wallet.") + } catch { + guard let error = error as? KeyStoringError else { + return XCTFail("Keychain: the error is expected to be KeyStoringError but it's \(error).") + } + + XCTAssertEqual(error.debugValue, KeyStoringError.uninitializedWallet.debugValue, "Keychain: error must be .uninitializedWallet") + } + } + + func testDeleteWallet() throws { + do { + let wallet = StoredWallet( + birthday: birthday, + language: language, + seedPhrase: seedPhrase, + version: RecoveryPhraseStorage.Constants.zcashKeychainVersion + ) + + guard let walletData = try RecoveryPhraseStorage.encode(object: wallet) else { + return XCTFail("`testDeleteWallet` encoding `walletData` failed.") + } + + do { + try setData(walletData, forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) + } catch { + XCTFail("`testDeleteWallet` storing `walletData` failed.") + } + + storage?.nukeWallet() + + let data = data(forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) + + XCTAssertEqual(data, nil, "Keychain: keychain is expected to not find anything for key `zcashStoredWallet` but received some data.") + } + } + + func testUpdateBirthdayOverNil() throws { + let wallet = StoredWallet( + birthday: nil, + language: language, + seedPhrase: seedPhrase, + version: RecoveryPhraseStorage.Constants.zcashKeychainVersion + ) + + guard let walletData = try RecoveryPhraseStorage.encode(object: wallet) else { + return XCTFail("`testUpdateBirthdayOverNil` encoding `walletData` failed.") + } + + do { + try setData(walletData, forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) + } catch { + XCTFail("`testUpdateBirthdayOverNil` storing `walletData` failed.") + } + + do { + try storage?.updateBirthday(birthday) + guard let data = data(forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) else { + return XCTFail("Keychain: no data found for key: `zcashStoredWallet`.") + } + + guard let walletReceived = try RecoveryPhraseStorage.decode(json: data, as: StoredWallet.self) else { + return XCTFail("Keychain: `walletReceived` can't be decoded.") + } + + XCTAssertEqual(birthday, walletReceived.birthday, "Keychain: stored birthday and retrieved one must be the same.") + XCTAssertEqual(seedPhrase, walletReceived.seedPhrase, "Keychain: stored seed phrase and retrieved one must be the same.") + } catch let err { + XCTFail("Keychain: no error is expected for `testUpdateBirthdayOverNil` but received. \(err)") + } + } + + func testUpdateBirthdayOverSomeBirthday() throws { + let wallet = StoredWallet( + birthday: birthday, + language: language, + seedPhrase: seedPhrase, + version: RecoveryPhraseStorage.Constants.zcashKeychainVersion + ) + let newBirthday = BlockHeight(87654321) + + guard let walletData = try RecoveryPhraseStorage.encode(object: wallet) else { + return XCTFail("`testUpdateBirthdayOverSomeBirthday` encoding `walletData` failed.") + } + + do { + try setData(walletData, forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) + } catch { + XCTFail("`testUpdateBirthdayOverSomeBirthday` storing `walletData` failed.") + } + + do { + try storage?.updateBirthday(newBirthday) + guard let data = data(forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) else { + return XCTFail("Keychain: no data found for key: `zcashStoredWallet`.") + } + + guard let walletReceived = try RecoveryPhraseStorage.decode(json: data, as: StoredWallet.self) else { + return XCTFail("Keychain: `walletReceived` can't be decoded.") + } + + XCTAssertEqual(newBirthday, walletReceived.birthday, "Keychain: stored birthday and retrieved one must be the same.") + XCTAssertEqual(seedPhrase, walletReceived.seedPhrase, "Keychain: stored seed phrase and retrieved one must be the same.") + } catch let err { + XCTFail("Keychain: no error is expected for `testUpdateBirthdayOverNil` but received. \(err)") + } + } + + func testUnsupportedVersion() throws { + let wallet = StoredWallet( + birthday: birthday, + language: language, + seedPhrase: seedPhrase, + /// older version + version: RecoveryPhraseStorage.Constants.zcashKeychainVersion - 1 + ) + + guard let walletData = try RecoveryPhraseStorage.encode(object: wallet) else { + return XCTFail("`testUnsupportedVersion` encoding `walletData` failed.") + } + + do { + try setData(walletData, forKey: RecoveryPhraseStorage.Constants.zcashStoredWallet) + } catch { + XCTFail("`testUnsupportedVersion` storing `walletData` failed.") + } + + do { + _ = try storage?.exportWallet() + + XCTFail("Keychain: `testUnsupportedVersion` should fail but received some wallet with correct version.") + } catch KeyStoringError.unsupportedVersion(let version) { + XCTAssertEqual( + version + 1, + RecoveryPhraseStorage.Constants.zcashKeychainVersion, + "Keychain: version should be \(RecoveryPhraseStorage.Constants.zcashKeychainVersion) but stored version is \(version)" + ) + } catch { + XCTFail("Keychain: `testUnsupportedVersion` should fail with `unsupportedVersion` error but threw \(error).") + } + } + + func testUnsupportedLanguage() throws { + do { + try storage?.importRecoveryPhrase(bip39: seedPhrase, birthday: birthday, language: .chinese) + + XCTFail("Keychain: `testUnsupportedLanguage` should fail but imported chinese language.") + } catch KeyStoringError.unsupportedLanguage(let languageToStore) { + XCTAssertEqual( + MnemonicLanguageType.chinese, + languageToStore, + "Keychain: language should be english but received \(languageToStore)" + ) + } catch { + XCTFail("Keychain: `testUnsupportedLanguage` should fail with `unsupportedLanguage` error but threw \(error).") + } + } +} + +// MARK: - Misc + +/// The followings methods are here purposely to not rely on `RecoveryPhraseStorage` in order to test functionality of JUST ONE method at a time +private extension RecoveryPhraseStorageTests { + private func setData( + account: String = "", + _ data: Data, + forKey: String + ) throws { + var query = RecoveryPhraseStorage.baseQuery(forAccount: account, andKey: forKey) + query[kSecValueData as String] = data as AnyObject + + SecItemAdd(query as CFDictionary, nil) + } + + private func data( + forKey: String, + account: String = "" + ) -> Data? { + let query = RecoveryPhraseStorage.restoreQuery(forAccount: account, andKey: forKey) + + var result: AnyObject? + _ = SecItemCopyMatching(query as CFDictionary, &result) + + return result as? Data + } + + @discardableResult + private func deleteData( + forKey: String, + account: String = "" + ) -> Bool { + let query = RecoveryPhraseStorage.baseQuery(forAccount: account, andKey: forKey) + + let status = SecItemDelete(query as CFDictionary) + + return status == noErr + } + + func updateData( + _ data: Data, + forKey: String, + account: String = "" + ) throws { + let query = RecoveryPhraseStorage.baseQuery(forAccount: account, andKey: forKey) + + let attributes:[ String: AnyObject ] = [ + kSecValueData as String: data as AnyObject + ] + + _ = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + } +}