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) + } +}