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 <adam@olemae.com>
This commit is contained in:
Lukas Korba 2022-03-11 15:51:46 +01:00
parent f92659d925
commit 366d30a00d
5 changed files with 596 additions and 19 deletions

View File

@ -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 = "<group>"; };
0D18581A272728D60046B928 /* PhraseChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhraseChip.swift; sourceTree = "<group>"; };
0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZcashSDKStubs.swift; sourceTree = "<group>"; };
0D1922F726BDEB3500052649 /* MockServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServices.swift; sourceTree = "<group>"; };
0D1C1AA227611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseDisplayReducerTests.swift; sourceTree = "<group>"; };
0D2ACE7F26C2C67100D62E3C /* Zboto.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Zboto.otf; sourceTree = "<group>"; };
0D354A0626D5A9D000315F45 /* Services.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Services.swift; sourceTree = "<group>"; };
@ -215,6 +215,8 @@
66D50667271D9B6100E51F0D /* NavigationButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationButtonStyle.swift; sourceTree = "<group>"; };
66DC733E271D88CC0053CBB6 /* StandardButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StandardButtonStyle.swift; sourceTree = "<group>"; };
9E2AC10027D8EF0B0042AA47 /* MnemonicSeedPhraseProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MnemonicSeedPhraseProvider.swift; sourceTree = "<group>"; };
9E2AC10227DA28200042AA47 /* RecoveryPhraseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseStorage.swift; sourceTree = "<group>"; };
9E2AC10527DA34610042AA47 /* RecoveryPhraseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseStorageTests.swift; sourceTree = "<group>"; };
9E2DF99827CF704D00649636 /* ImportWalletStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportWalletStore.swift; sourceTree = "<group>"; };
9E2DF99A27CF704D00649636 /* ImportSeedEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportSeedEditor.swift; sourceTree = "<group>"; };
9E2DF99B27CF704D00649636 /* ImportWalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportWalletView.swift; sourceTree = "<group>"; };
@ -315,7 +317,6 @@
isa = PBXGroup;
children = (
0D1922F126BDE29300052649 /* ZcashSDKStubs.swift */,
0D1922F726BDEB3500052649 /* MockServices.swift */,
);
path = Stubs;
sourceTree = "<group>";
@ -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 = "<group>";
@ -654,6 +657,14 @@
path = CircularFrame;
sourceTree = "<group>";
};
9E2AC10427DA34450042AA47 /* Util */ = {
isa = PBXGroup;
children = (
9E2AC10527DA34610042AA47 /* RecoveryPhraseStorageTests.swift */,
);
path = Util;
sourceTree = "<group>";
};
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 */,
);

View File

@ -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"
}
},
{

View File

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

View File

@ -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<T: Decodable>(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<T: Codable>(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()
}
}

View File

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