* Store seed and raw private keys using symmetric encryption based on Diffie-Hellman with ECDSA keys stored in Secure Enclave, instead of storing raw private keys using storing AES-encrypted keystore files on disk and the AES key into keychain * Set User-presence required flag when writing seed and private keys to keychain so Touch ID/Face ID is required when reading them from the keychain * Back up alerts to prompt user to back up wallet to go to "yellow bar" status * Allow user to elevate security making wallets go to "green bar" * Visually guide user to where wallet address can be found after wallet has been created * Change wording of delete wallet action to "Lose this wallet" to make the effect obvious to the user * Inform user that it's a bad idea to take a screenshot of their seed phrase during back up/verify after they have done it. Can't prevent itpull/1272/head
parent
7ab60defd0
commit
1e6a51b7fe
@ -0,0 +1,79 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
class AccountViewTableSectionHeader: UIView { |
||||
enum HeaderType: Int { |
||||
case hdWallet = 0 |
||||
case keystoreWallet = 1 |
||||
case watchedWallet = 2 |
||||
|
||||
var title: String { |
||||
switch self { |
||||
case .hdWallet: |
||||
return R.string.localizable.walletTypesHdWallets() |
||||
case .keystoreWallet: |
||||
return R.string.localizable.walletTypesKeystoreWallets() |
||||
case .watchedWallet: |
||||
return R.string.localizable.walletTypesWatchedWallets() |
||||
} |
||||
} |
||||
} |
||||
|
||||
private let label = UILabel() |
||||
private var topConstraint: NSLayoutConstraint? |
||||
private var heightConstraint: NSLayoutConstraint? |
||||
private var constraintsWhenVisible: [NSLayoutConstraint]? |
||||
|
||||
override init(frame: CGRect) { |
||||
super.init(frame: CGRect()) |
||||
|
||||
label.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(label) |
||||
let topConstraint = label.topAnchor.constraint(equalTo: topAnchor, constant: 10) |
||||
let constraintsWhenVisible = [ |
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), |
||||
label.trailingAnchor.constraint(equalTo: trailingAnchor), |
||||
topConstraint, |
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -7) |
||||
] |
||||
|
||||
NSLayoutConstraint.activate(constraintsWhenVisible) |
||||
|
||||
self.topConstraint = topConstraint |
||||
//UIKit doesn't like headers with a height of 0 |
||||
self.heightConstraint = heightAnchor.constraint(equalToConstant: 1) |
||||
self.constraintsWhenVisible = constraintsWhenVisible |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
func configure(type: HeaderType, shouldHide: Bool) { |
||||
backgroundColor = Colors.appWhite |
||||
|
||||
label.backgroundColor = Colors.appWhite |
||||
label.textColor = UIColor(red: 141, green: 141, blue: 141) |
||||
label.font = Fonts.semibold(size: 15)! |
||||
label.text = type.title |
||||
label.isHidden = shouldHide |
||||
|
||||
heightConstraint?.isActive = shouldHide |
||||
if shouldHide { |
||||
NSLayoutConstraint.deactivate(constraintsWhenVisible!) |
||||
} else { |
||||
NSLayoutConstraint.activate(constraintsWhenVisible!) |
||||
} |
||||
|
||||
switch type { |
||||
case .hdWallet: |
||||
topConstraint?.constant = 0 |
||||
case .keystoreWallet: |
||||
topConstraint?.constant = 17 |
||||
case .watchedWallet: |
||||
topConstraint?.constant = 10 |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,21 @@ |
||||
{ |
||||
"images" : [ |
||||
{ |
||||
"idiom" : "universal", |
||||
"scale" : "1x" |
||||
}, |
||||
{ |
||||
"idiom" : "universal", |
||||
"scale" : "2x" |
||||
}, |
||||
{ |
||||
"idiom" : "universal", |
||||
"filename" : "biometric-lock@3x.png", |
||||
"scale" : "3x" |
||||
} |
||||
], |
||||
"info" : { |
||||
"version" : 1, |
||||
"author" : "xcode" |
||||
} |
||||
} |
After Width: | Height: | Size: 79 KiB |
@ -0,0 +1,21 @@ |
||||
{ |
||||
"images" : [ |
||||
{ |
||||
"idiom" : "universal", |
||||
"scale" : "1x" |
||||
}, |
||||
{ |
||||
"idiom" : "universal", |
||||
"scale" : "2x" |
||||
}, |
||||
{ |
||||
"idiom" : "universal", |
||||
"filename" : "keystore-introduction@3x.png", |
||||
"scale" : "3x" |
||||
} |
||||
], |
||||
"info" : { |
||||
"version" : 1, |
||||
"author" : "xcode" |
||||
} |
||||
} |
After Width: | Height: | Size: 39 KiB |
@ -0,0 +1,12 @@ |
||||
{ |
||||
"images" : [ |
||||
{ |
||||
"idiom" : "universal", |
||||
"filename" : "successOverlay.pdf" |
||||
} |
||||
], |
||||
"info" : { |
||||
"version" : 1, |
||||
"author" : "xcode" |
||||
} |
||||
} |
Binary file not shown.
@ -0,0 +1,12 @@ |
||||
{ |
||||
"images" : [ |
||||
{ |
||||
"idiom" : "universal", |
||||
"filename" : "toggle-password.pdf" |
||||
} |
||||
], |
||||
"info" : { |
||||
"version" : 1, |
||||
"author" : "xcode" |
||||
} |
||||
} |
Binary file not shown.
@ -1,447 +0,0 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import BigInt |
||||
import Foundation |
||||
import Result |
||||
import KeychainSwift |
||||
import CryptoSwift |
||||
import TrustKeystore |
||||
|
||||
enum EtherKeystoreError: LocalizedError { |
||||
case protectionDisabled |
||||
} |
||||
|
||||
open class EtherKeystore: Keystore { |
||||
private struct Keys { |
||||
static let recentlyUsedAddress: String = "recentlyUsedAddress" |
||||
static let watchAddresses = "watchAddresses" |
||||
} |
||||
|
||||
private let keychain: KeychainSwift |
||||
private let datadir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] |
||||
private let keyStore: KeyStore |
||||
private let defaultKeychainAccess: KeychainSwiftAccessOptions = .accessibleWhenUnlockedThisDeviceOnly |
||||
private let userDefaults: UserDefaults |
||||
|
||||
let keystoreDirectory: URL |
||||
|
||||
public init( |
||||
keychain: KeychainSwift = KeychainSwift(keyPrefix: Constants.keychainKeyPrefix), |
||||
keyStoreSubfolder: String = "/keystore", |
||||
userDefaults: UserDefaults = UserDefaults.standard |
||||
) throws { |
||||
if !UIApplication.shared.isProtectedDataAvailable { |
||||
throw EtherKeystoreError.protectionDisabled |
||||
} |
||||
self.keystoreDirectory = URL(fileURLWithPath: datadir + keyStoreSubfolder) |
||||
self.keychain = keychain |
||||
self.keychain.synchronizable = false |
||||
self.keyStore = try KeyStore(keydir: keystoreDirectory) |
||||
self.userDefaults = userDefaults |
||||
} |
||||
|
||||
var hasWallets: Bool { |
||||
return !wallets.isEmpty |
||||
} |
||||
|
||||
private var watchAddresses: [String] { |
||||
set { |
||||
let data = NSKeyedArchiver.archivedData(withRootObject: newValue) |
||||
return userDefaults.set(data, forKey: Keys.watchAddresses) |
||||
} |
||||
get { |
||||
guard let data = userDefaults.data(forKey: Keys.watchAddresses) else { return [] } |
||||
return NSKeyedUnarchiver.unarchiveObject(with: data) as? [String] ?? [] |
||||
} |
||||
} |
||||
|
||||
var recentlyUsedWallet: Wallet? { |
||||
set { |
||||
keychain.set(newValue?.address.eip55String ?? "", forKey: Keys.recentlyUsedAddress, withAccess: defaultKeychainAccess) |
||||
} |
||||
get { |
||||
guard let address = keychain.get(Keys.recentlyUsedAddress) else { return nil } |
||||
return wallets.filter { $0.address.sameContract(as: address) }.first |
||||
} |
||||
} |
||||
|
||||
static var current: Wallet? { |
||||
do { |
||||
return try EtherKeystore().recentlyUsedWallet |
||||
} catch { |
||||
return .none |
||||
} |
||||
} |
||||
|
||||
// Async |
||||
@available(iOS 10.0, *) |
||||
func createAccount(with password: String, completion: @escaping (Result<Account, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { return } |
||||
let account = strongSelf.createAccount(password: password) |
||||
DispatchQueue.main.async { |
||||
completion(.success(account)) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func importWallet(type: ImportType, completion: @escaping (Result<Wallet, KeystoreError>) -> Void) { |
||||
let newPassword = PasswordGenerator.generateRandom() |
||||
switch type { |
||||
case .keystore(let string, let password): |
||||
importKeystore( |
||||
value: string, |
||||
password: password, |
||||
newPassword: newPassword |
||||
) { result in |
||||
switch result { |
||||
case .success(let account): |
||||
completion(.success(Wallet(type: .real(account)))) |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
case .privateKey(let privateKey): |
||||
keystore(for: privateKey, password: newPassword) { [weak self] result in |
||||
guard let strongSelf = self else { return } |
||||
switch result { |
||||
case .success(let value): |
||||
strongSelf.importKeystore( |
||||
value: value, |
||||
password: newPassword, |
||||
newPassword: newPassword |
||||
) { result in |
||||
switch result { |
||||
case .success(let account): |
||||
completion(.success(Wallet(type: .real(account)))) |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
case .mnemonic: |
||||
let key = "" |
||||
// TODO: Implement it |
||||
keystore(for: key, password: newPassword) { [weak self] result in |
||||
guard let strongSelf = self else { return } |
||||
switch result { |
||||
case .success(let value): |
||||
strongSelf.importKeystore( |
||||
value: value, |
||||
password: newPassword, |
||||
newPassword: newPassword |
||||
) { result in |
||||
switch result { |
||||
case .success(let account): |
||||
completion(.success(Wallet(type: .real(account)))) |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
case .watch(let address): |
||||
guard !watchAddresses.contains(where: { address.sameContract(as: $0) }) else { |
||||
completion(.failure(.duplicateAccount)) |
||||
return |
||||
} |
||||
watchAddresses = [watchAddresses, [address.eip55String]].flatMap { $0 } |
||||
completion(.success(Wallet(type: .watch(address)))) |
||||
} |
||||
} |
||||
|
||||
func keystore(for privateKey: String, password: String, completion: @escaping (Result<String, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { return } |
||||
let keystore = strongSelf.convertPrivateKeyToKeystoreFile( |
||||
privateKey: privateKey, |
||||
passphrase: password |
||||
) |
||||
DispatchQueue.main.async { |
||||
switch keystore { |
||||
case .success(let result): |
||||
completion(.success(result.jsonString ?? "")) |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
func importKeystore(value: String, password: String, newPassword: String, completion: @escaping (Result<Account, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { return } |
||||
let result = strongSelf.importKeystore(value: value, password: password, newPassword: newPassword) |
||||
DispatchQueue.main.async { |
||||
switch result { |
||||
case .success(let account): |
||||
completion(.success(account)) |
||||
case .failure(let error): |
||||
completion(.failure(error)) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
func createAccount(password: String) -> Account { |
||||
let account = try! keyStore.createAccount(password: password) |
||||
let _ = setPassword(password, for: account) |
||||
return account |
||||
} |
||||
|
||||
func importKeystore(value: String, password: String, newPassword: String) -> Result<Account, KeystoreError> { |
||||
guard let data = value.data(using: .utf8) else { |
||||
return (.failure(.failedToParseJSON)) |
||||
} |
||||
do { |
||||
let account = try keyStore.import(json: data, password: password, newPassword: newPassword) |
||||
let _ = setPassword(newPassword, for: account) |
||||
return .success(account) |
||||
} catch { |
||||
if case KeyStore.Error.accountAlreadyExists = error { |
||||
return .failure(.duplicateAccount) |
||||
} else { |
||||
return .failure(.failedToImport(error)) |
||||
} |
||||
} |
||||
} |
||||
|
||||
var wallets: [Wallet] { |
||||
let addresses = watchAddresses.compactMap { AlphaWallet.Address(string: $0) } |
||||
return [ |
||||
keyStore.accounts.map { Wallet(type: .real($0)) }, |
||||
addresses.map { Wallet(type: .watch($0)) }, |
||||
].flatMap { $0 } |
||||
} |
||||
|
||||
func export(account: Account, password: String, newPassword: String) -> Result<String, KeystoreError> { |
||||
let result = exportData(account: account, password: password, newPassword: newPassword) |
||||
switch result { |
||||
case .success(let data): |
||||
let string = String(data: data, encoding: .utf8) ?? "" |
||||
return .success(string) |
||||
case .failure(let error): |
||||
return .failure(error) |
||||
} |
||||
} |
||||
|
||||
func export(account: Account, password: String, newPassword: String, completion: @escaping (Result<String, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { return } |
||||
let result = strongSelf.export(account: account, password: password, newPassword: newPassword) |
||||
DispatchQueue.main.async { |
||||
completion(result) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func exportData(account: Account, password: String, newPassword: String) -> Result<Data, KeystoreError> { |
||||
guard let account = getAccount(for: account.address) else { |
||||
return .failure(.accountNotFound) |
||||
} |
||||
do { |
||||
let data = try keyStore.export(account: account, password: password, newPassword: newPassword) |
||||
return (.success(data)) |
||||
} catch { |
||||
return (.failure(.failedToDecryptKey)) |
||||
} |
||||
|
||||
} |
||||
|
||||
func exportPrivateKey(account: Account) -> Result<Data, KeystoreError> { |
||||
guard let password = getPassword(for: account) else { |
||||
return .failure(KeystoreError.accountNotFound) |
||||
} |
||||
do { |
||||
let privateKey = try keyStore.exportPrivateKey(account: account, password: password) |
||||
return .success(privateKey) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToExportPrivateKey) |
||||
} |
||||
|
||||
} |
||||
|
||||
@discardableResult func delete(wallet: Wallet) -> Result<Void, KeystoreError> { |
||||
switch wallet.type { |
||||
case .real(let account): |
||||
guard let account = getAccount(for: account.address) else { |
||||
return .failure(.accountNotFound) |
||||
} |
||||
|
||||
guard let password = getPassword(for: account) else { |
||||
return .failure(.failedToDeleteAccount) |
||||
} |
||||
|
||||
do { |
||||
try keyStore.delete(account: account, password: password) |
||||
return .success(()) |
||||
} catch { |
||||
return .failure(.failedToDeleteAccount) |
||||
} |
||||
case .watch(let address): |
||||
watchAddresses = watchAddresses.filter { $0 != address.eip55String } |
||||
return .success(()) |
||||
} |
||||
} |
||||
|
||||
func delete(wallet: Wallet, completion: @escaping (Result<Void, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { return } |
||||
let result = strongSelf.delete(wallet: wallet) |
||||
DispatchQueue.main.async { |
||||
completion(result) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func updateAccount(account: Account, password: String, newPassword: String) -> Result<Void, KeystoreError> { |
||||
guard let account = getAccount(for: account.address) else { |
||||
return .failure(.accountNotFound) |
||||
} |
||||
|
||||
do { |
||||
try keyStore.update(account: account, password: password, newPassword: newPassword) |
||||
return .success(()) |
||||
} catch { |
||||
return .failure(.failedToUpdatePassword) |
||||
} |
||||
} |
||||
|
||||
func signPersonalMessage(_ message: Data, for account: Account) -> Result<Data, KeystoreError> { |
||||
let prefix = "\u{19}Ethereum Signed Message:\n\(message.count)".data(using: .utf8)! |
||||
return signMessage(prefix + message, for: account) |
||||
} |
||||
|
||||
func signHash(_ hash: Data, for account: Account) -> Result<Data, KeystoreError> { |
||||
guard |
||||
let password = getPassword(for: account) else { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
do { |
||||
var data = try keyStore.signHash(hash, account: account, password: password) |
||||
// TODO: Make it configurable, instead of overriding last byte. |
||||
data[64] += 27 |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
} |
||||
|
||||
func signTypedMessage(_ datas: [EthTypedData], for account: Account) -> Result<Data, KeystoreError> { |
||||
let schemas = datas.map { $0.schemaData }.reduce(Data(), { $0 + $1 }).sha3(.keccak256) |
||||
let values = datas.map { $0.typedData }.reduce(Data(), { $0 + $1 }).sha3(.keccak256) |
||||
let combined = (schemas + values).sha3(.keccak256) |
||||
return signHash(combined, for: account) |
||||
} |
||||
|
||||
func signMessage(_ message: Data, for account: Account) -> Result<Data, KeystoreError> { |
||||
return signHash(message.sha3(.keccak256), for: account) |
||||
} |
||||
|
||||
func signMessageBulk(_ data: [Data], for account: Account) -> Result<[Data], KeystoreError> { |
||||
guard |
||||
let password = getPassword(for: account) else { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
do { |
||||
var messageHashes = [Data]() |
||||
for i in 0...data.count - 1 { |
||||
let hash = data[i].sha3(.keccak256) |
||||
messageHashes.append(hash) |
||||
} |
||||
var data = try keyStore.signHashes(messageHashes, account: account, password: password) |
||||
// TODO: Make it configurable, instead of overriding last byte. |
||||
for i in 0...data.count - 1 { |
||||
data[i][64] += 27 |
||||
} |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
} |
||||
|
||||
public func signMessageData(_ message: Data?, for account: Account) -> Result<Data, KeystoreError> { |
||||
guard |
||||
let hash = message?.sha3(.keccak256), |
||||
let password = getPassword(for: account) else { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
do { |
||||
var data = try keyStore.signHash(hash, account: account, password: password) |
||||
data[64] += 27 |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
} |
||||
|
||||
func signTransaction(_ transaction: UnsignedTransaction) -> Result<Data, KeystoreError> { |
||||
guard let account = keyStore.account(for: transaction.account.address) else { |
||||
return .failure(.failedToSignTransaction) |
||||
} |
||||
guard let password = getPassword(for: account) else { |
||||
return .failure(.failedToSignTransaction) |
||||
} |
||||
|
||||
let signer: Signer |
||||
if transaction.server.chainID == 0 { |
||||
signer = HomesteadSigner() |
||||
} else { |
||||
signer = EIP155Signer(server: transaction.server) |
||||
} |
||||
|
||||
do { |
||||
let hash = signer.hash(transaction: transaction) |
||||
let signature = try keyStore.signHash(hash, account: account, password: password) |
||||
let (r, s, v) = signer.values(transaction: transaction, signature: signature) |
||||
let data = RLP.encode([ |
||||
transaction.nonce, |
||||
transaction.gasPrice, |
||||
transaction.gasLimit, |
||||
transaction.to?.data ?? Data(), |
||||
transaction.value, |
||||
transaction.data, |
||||
v, r, s, |
||||
])! |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(.failedToSignTransaction) |
||||
} |
||||
} |
||||
|
||||
func getPassword(for account: Account) -> String? { |
||||
return keychain.get(account.address.eip55String.lowercased()) |
||||
} |
||||
|
||||
@discardableResult |
||||
func setPassword(_ password: String, for account: Account) -> Bool { |
||||
return keychain.set(password, forKey: account.address.eip55String.lowercased(), withAccess: defaultKeychainAccess) |
||||
} |
||||
|
||||
func getAccount(for address: AlphaWallet.Address) -> Account? { |
||||
return getAccount(for: .init(address: address)) |
||||
} |
||||
|
||||
func getAccount(for address: Address) -> Account? { |
||||
return keyStore.account(for: address) |
||||
} |
||||
|
||||
func convertPrivateKeyToKeystoreFile(privateKey: String, passphrase: String) -> Result<[String: Any], KeystoreError> { |
||||
guard let data = Data(hexString: privateKey) else { |
||||
return .failure(KeystoreError.failedToImportPrivateKey) |
||||
} |
||||
do { |
||||
let key = try KeystoreKey(password: passphrase, key: data) |
||||
let data = try JSONEncoder().encode(key) |
||||
let dict = try JSONSerialization.jsonObject(with: data, options: []) as! [String: Any] |
||||
return .success(dict) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToImportPrivateKey) |
||||
} |
||||
} |
||||
} |
@ -1,33 +0,0 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import Foundation |
||||
import Result |
||||
import TrustKeystore |
||||
|
||||
protocol Keystore { |
||||
var hasWallets: Bool { get } |
||||
var wallets: [Wallet] { get } |
||||
var keystoreDirectory: URL { get } |
||||
var recentlyUsedWallet: Wallet? { get set } |
||||
static var current: Wallet? { get } |
||||
@available(iOS 10.0, *) |
||||
func createAccount(with password: String, completion: @escaping (Result<Account, KeystoreError>) -> Void) |
||||
func importWallet(type: ImportType, completion: @escaping (Result<Wallet, KeystoreError>) -> Void) |
||||
func keystore(for privateKey: String, password: String, completion: @escaping (Result<String, KeystoreError>) -> Void) |
||||
func importKeystore(value: String, password: String, newPassword: String, completion: @escaping (Result<Account, KeystoreError>) -> Void) |
||||
func createAccount(password: String) -> Account |
||||
func importKeystore(value: String, password: String, newPassword: String) -> Result<Account, KeystoreError> |
||||
func export(account: Account, password: String, newPassword: String) -> Result<String, KeystoreError> |
||||
func export(account: Account, password: String, newPassword: String, completion: @escaping (Result<String, KeystoreError>) -> Void) |
||||
func exportData(account: Account, password: String, newPassword: String) -> Result<Data, KeystoreError> |
||||
@discardableResult func delete(wallet: Wallet) -> Result<Void, KeystoreError> |
||||
func delete(wallet: Wallet, completion: @escaping (Result<Void, KeystoreError>) -> Void) |
||||
func updateAccount(account: Account, password: String, newPassword: String) -> Result<Void, KeystoreError> |
||||
func signPersonalMessage(_ data: Data, for account: Account) -> Result<Data, KeystoreError> |
||||
func signTypedMessage(_ datas: [EthTypedData], for account: Account) -> Result<Data, KeystoreError> |
||||
func signMessage(_ data: Data, for account: Account) -> Result<Data, KeystoreError> |
||||
func signHash(_ data: Data, for account: Account) -> Result<Data, KeystoreError> |
||||
func signTransaction(_ signTransaction: UnsignedTransaction) -> Result<Data, KeystoreError> |
||||
func getPassword(for account: Account) -> String? |
||||
func convertPrivateKeyToKeystoreFile(privateKey: String, passphrase: String) -> Result<[String: Any], KeystoreError> |
||||
} |
@ -1,64 +1,381 @@ |
||||
// Copyright © 2018 Stormbird PTE. LTD. |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
import BigInt |
||||
|
||||
protocol PromptBackupCoordinatorDelegate: class { |
||||
func viewControllerForPresenting(in coordinator: PromptBackupCoordinator) -> UIViewController? |
||||
func didFinish(in coordinator: PromptBackupCoordinator) |
||||
protocol PromptBackupCoordinatorProminentPromptDelegate: class { |
||||
var viewControllerToShowBackupLaterAlert: UIViewController { get } |
||||
|
||||
func updatePrompt(inCoordinator coordinator: PromptBackupCoordinator) |
||||
} |
||||
|
||||
protocol PromptBackupCoordinatorSubtlePromptDelegate: class { |
||||
var viewControllerToShowBackupLaterAlert: UIViewController { get } |
||||
|
||||
func updatePrompt(inCoordinator coordinator: PromptBackupCoordinator) |
||||
} |
||||
|
||||
///We allow user to switch wallets, so it's important to know which wallet we are prompting for. It might not be the current wallet |
||||
class PromptBackupCoordinator: Coordinator { |
||||
private static let secondsInAMonth = TimeInterval(30*24*60*60) |
||||
private static let thresholdNativeCryptoCurrencyAmountInUsdToPromptBackup = Double(200) |
||||
|
||||
private let documentsDirectory = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]) |
||||
private let filename = "backupState.json" |
||||
lazy private var fileUrl = documentsDirectory.appendingPathComponent(filename) |
||||
private let keystore: Keystore |
||||
private var walletAddress: AlphaWallet.Address |
||||
private let wallet: Wallet |
||||
private let config: Config |
||||
//TODO this should be the total of mainnets instead of just Ethereum mainnet |
||||
private var nativeCryptoCurrencyDollarValueInUsd: Double = 0 |
||||
|
||||
var prominentPromptView: UIView? |
||||
var subtlePromptView: UIView? |
||||
var coordinators: [Coordinator] = [] |
||||
weak var delegate: PromptBackupCoordinatorDelegate? |
||||
weak var prominentPromptDelegate: PromptBackupCoordinatorProminentPromptDelegate? |
||||
weak var subtlePromptDelegate: PromptBackupCoordinatorSubtlePromptDelegate? |
||||
|
||||
init(keystore: Keystore, walletAddress: AlphaWallet.Address, config: Config) { |
||||
init(keystore: Keystore, wallet: Wallet, config: Config) { |
||||
self.keystore = keystore |
||||
self.walletAddress = walletAddress |
||||
self.wallet = wallet |
||||
self.config = config |
||||
} |
||||
|
||||
func start() { |
||||
guard let vc = delegate?.viewControllerForPresenting(in: self) else { |
||||
finish() |
||||
return |
||||
migrateOldData() |
||||
guard canBackupWallet else { return } |
||||
setUpAndPromptIfWalletHasNotBeenPromptedBefore() |
||||
showCreateBackupAfterIntervalPrompt() |
||||
showHideCurrentPrompt() |
||||
} |
||||
|
||||
private func setUpAndPromptIfWalletHasNotBeenPromptedBefore() { |
||||
guard !hasState else { return } |
||||
updateState { state in |
||||
state.backupState[wallet.address] = .init(shownNativeCryptoCurrencyReceivedPrompt: false, timeToShowIntervalPassedPrompt: nil, shownNativeCryptoCurrencyDollarValueExceedThresholdPrompt: false, lastBackedUpTime: nil, isImported: false) |
||||
} |
||||
let coordinator = WalletCoordinator(config: config, keystore: keystore) |
||||
coordinator.delegate = self |
||||
let proceed = coordinator.start(.backupWallet(address: walletAddress)) |
||||
guard proceed else { |
||||
finish() |
||||
return |
||||
showCreateBackupAfterWalletCreationPrompt() |
||||
} |
||||
|
||||
func showHideCurrentPrompt() { |
||||
if let prompt = readState()?.prompt[wallet.address] { |
||||
switch prompt { |
||||
case .newWallet: |
||||
createBackupAfterWalletCreationView() |
||||
case .intervalPassed: |
||||
createBackupAfterIntervalView() |
||||
case .nativeCryptoCurrencyDollarValueExceededThreshold: |
||||
createBackupAfterExceedingThresholdView() |
||||
case .receivedNativeCryptoCurrency(let nativeCryptoCurrency): |
||||
createBackupAfterReceiveNativeCryptoCurrencyView(nativeCryptoCurrency: nativeCryptoCurrency) |
||||
} |
||||
} else { |
||||
removeBackupView() |
||||
} |
||||
vc.present(coordinator.navigationController, animated: true, completion: nil) |
||||
addCoordinator(coordinator) |
||||
} |
||||
|
||||
func finish() { |
||||
delegate?.didFinish(in: self) |
||||
private func informDelegatesPromptHasChanged() { |
||||
subtlePromptDelegate?.updatePrompt(inCoordinator: self) |
||||
prominentPromptDelegate?.updatePrompt(inCoordinator: self) |
||||
} |
||||
|
||||
private func migrateOldData() { |
||||
guard !FileManager.default.fileExists(atPath: fileUrl.path) else { return } |
||||
let addressesAlreadyPromptedForBackup = config.oldWalletAddressesAlreadyPromptedForBackUp |
||||
var walletsBackupState: WalletsBackupState = .init() |
||||
for eachAlreadyBackedUp in addressesAlreadyPromptedForBackup { |
||||
guard let walletAddress = AlphaWallet.Address(string: eachAlreadyBackedUp) else { continue } |
||||
walletsBackupState.prompt[walletAddress] = nil |
||||
//We'll just take the last backed up time as when this migration runs |
||||
walletsBackupState.backupState[walletAddress] = .init(shownNativeCryptoCurrencyReceivedPrompt: true, timeToShowIntervalPassedPrompt: nil, shownNativeCryptoCurrencyDollarValueExceedThresholdPrompt: true, lastBackedUpTime: Date(), isImported: false) |
||||
} |
||||
writeState(walletsBackupState) |
||||
} |
||||
|
||||
private func createBackupViewImpl(viewModel: PromptBackupWalletViewViewModel) -> UIView { |
||||
let view = PromptBackupWalletView(viewModel: viewModel) |
||||
view.delegate = self |
||||
view.configure() |
||||
return view |
||||
} |
||||
|
||||
//TODO not the best way to watch Ether balance |
||||
func listenToNativeCryptoCurrencyBalance(withTokenCollection tokenCollection: TokenCollection) { |
||||
tokenCollection.subscribe { [weak self] result in |
||||
guard let strongSelf = self else { return } |
||||
switch result { |
||||
case .success(let viewModel): |
||||
if let nativeCryptoCurrencyToken = viewModel.nativeCryptoCurrencyToken(forServer: .main) { |
||||
let dollarValue = viewModel.amount(for: nativeCryptoCurrencyToken) |
||||
if !dollarValue.isZero { |
||||
self?.showCreateBackupAfterExceedThresholdPrompt(valueInUsd: dollarValue) |
||||
} |
||||
} |
||||
case .failure(let error): |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
//MARK: Update UI |
||||
|
||||
private func createBackupAfterWalletCreationView() { |
||||
let view = createBackupViewImpl(viewModel: PromptBackupWalletAfterWalletCreationViewViewModel(walletAddress: wallet.address)) |
||||
prominentPromptView = nil |
||||
subtlePromptView = view |
||||
informDelegatesPromptHasChanged() |
||||
} |
||||
|
||||
private func createBackupAfterReceiveNativeCryptoCurrencyView(nativeCryptoCurrency: BigInt) { |
||||
let view = createBackupViewImpl(viewModel: PromptBackupWalletAfterReceivingNativeCryptoCurrencyViewViewModel(walletAddress: wallet.address, nativeCryptoCurrency: nativeCryptoCurrency)) |
||||
prominentPromptView = view |
||||
subtlePromptView = nil |
||||
informDelegatesPromptHasChanged() |
||||
} |
||||
|
||||
private func createBackupAfterIntervalView() { |
||||
let view = createBackupViewImpl(viewModel: PromptBackupWalletAfterIntervalViewViewModel(walletAddress: wallet.address)) |
||||
prominentPromptView = view |
||||
subtlePromptView = nil |
||||
informDelegatesPromptHasChanged() |
||||
} |
||||
|
||||
private func createBackupAfterExceedingThresholdView() { |
||||
let view = createBackupViewImpl(viewModel: PromptBackupWalletAfterExceedingThresholdViewViewModel(walletAddress: wallet.address, dollarValueInUsd: nativeCryptoCurrencyDollarValueInUsd)) |
||||
prominentPromptView = view |
||||
subtlePromptView = nil |
||||
informDelegatesPromptHasChanged() |
||||
} |
||||
|
||||
private func removeBackupView() { |
||||
prominentPromptView = nil |
||||
subtlePromptView = nil |
||||
informDelegatesPromptHasChanged() |
||||
} |
||||
|
||||
//MARK: Set current prompt and state |
||||
|
||||
private func showCreateBackupAfterWalletCreationPrompt() { |
||||
guard canBackupWallet else { return } |
||||
guard !isBackedUp else { return } |
||||
guard !isImported else { return } |
||||
updateState { state in |
||||
state.prompt[wallet.address] = .newWallet |
||||
writeState(state) |
||||
} |
||||
showHideCurrentPrompt() |
||||
} |
||||
|
||||
func showCreateBackupAfterReceiveNativeCryptoCurrencyPrompt(nativeCryptoCurrency: BigInt) { |
||||
guard canBackupWallet else { return } |
||||
guard !isBackedUp else { return } |
||||
guard !isImported else { return } |
||||
guard !hasShownNativeCryptoCurrencyReceivedPrompt else { return } |
||||
updateState { state in |
||||
state.prompt[wallet.address] = .receivedNativeCryptoCurrency(nativeCryptoCurrency) |
||||
state.backupState[wallet.address]?.shownNativeCryptoCurrencyReceivedPrompt = true |
||||
writeState(state) |
||||
} |
||||
showHideCurrentPrompt() |
||||
} |
||||
|
||||
private func showCreateBackupAfterIntervalPrompt() { |
||||
guard canBackupWallet else { return } |
||||
guard !isBackedUp else { return } |
||||
guard !isImported else { return } |
||||
guard let time = timeToShowIntervalPassedPrompt else { return } |
||||
guard time.isEarlierThan(date: .init()) else { return } |
||||
updateState { state in |
||||
state.prompt[wallet.address] = .intervalPassed |
||||
state.backupState[wallet.address]?.timeToShowIntervalPassedPrompt = nil |
||||
writeState(state) |
||||
} |
||||
showHideCurrentPrompt() |
||||
} |
||||
|
||||
private func showCreateBackupAfterExceedThresholdPrompt(valueInUsd: Double) { |
||||
nativeCryptoCurrencyDollarValueInUsd = valueInUsd |
||||
guard canBackupWallet else { return } |
||||
guard !isBackedUp else { return } |
||||
guard !isImported else { return } |
||||
let hasExceededThreshold = valueInUsd >= PromptBackupCoordinator.thresholdNativeCryptoCurrencyAmountInUsdToPromptBackup |
||||
let toShow: Bool |
||||
if isShowingExceededThresholdPrompt { |
||||
if hasExceededThreshold { |
||||
toShow = true |
||||
} else { |
||||
toShow = false |
||||
} |
||||
} else { |
||||
guard !hasShownExceededThresholdPrompt else { return } |
||||
guard hasExceededThreshold else { return } |
||||
toShow = true |
||||
} |
||||
if toShow { |
||||
updateState { state in |
||||
state.prompt[wallet.address] = .nativeCryptoCurrencyDollarValueExceededThreshold |
||||
state.backupState[wallet.address]?.shownNativeCryptoCurrencyDollarValueExceedThresholdPrompt = true |
||||
writeState(state) |
||||
} |
||||
showHideCurrentPrompt() |
||||
} else { |
||||
updateState { state in |
||||
state.prompt[wallet.address] = nil |
||||
writeState(state) |
||||
} |
||||
showHideCurrentPrompt() |
||||
} |
||||
} |
||||
|
||||
func markBackupDone() { |
||||
guard canBackupWallet else { return } |
||||
updateState { state in |
||||
state.prompt[wallet.address] = nil |
||||
state.backupState[wallet.address]?.lastBackedUpTime = Date() |
||||
writeState(state) |
||||
} |
||||
} |
||||
|
||||
private func remindLater() { |
||||
guard canBackupWallet else { return } |
||||
guard !isBackedUp else { return } |
||||
guard !isImported else { return } |
||||
updateState { state in |
||||
state.prompt[wallet.address] = nil |
||||
state.backupState[wallet.address]?.timeToShowIntervalPassedPrompt = Date(timeIntervalSinceNow: PromptBackupCoordinator.secondsInAMonth) |
||||
writeState(state) |
||||
} |
||||
} |
||||
|
||||
func markWalletAsImported() { |
||||
updateState { state in |
||||
state.prompt[wallet.address] = nil |
||||
if let backupState = state.backupState[wallet.address] { |
||||
state.backupState[wallet.address]?.isImported = true |
||||
} else { |
||||
state.backupState[wallet.address] = .init(shownNativeCryptoCurrencyReceivedPrompt: false, timeToShowIntervalPassedPrompt: nil, shownNativeCryptoCurrencyDollarValueExceedThresholdPrompt: false, lastBackedUpTime: nil, isImported: true) |
||||
} |
||||
writeState(state) |
||||
} |
||||
} |
||||
|
||||
func deleteWallet() { |
||||
updateState { state in |
||||
state.prompt[wallet.address] = nil |
||||
state.backupState[wallet.address] = nil |
||||
writeState(state) |
||||
} |
||||
} |
||||
|
||||
//MARK: State |
||||
|
||||
private var hasState: Bool { |
||||
guard let state = WalletsBackupState.load(fromUrl: fileUrl) else { return false } |
||||
return state.backupState[wallet.address] != nil |
||||
} |
||||
|
||||
private var hasShownNativeCryptoCurrencyReceivedPrompt: Bool { |
||||
if let shown = readState()?.backupState[wallet.address]?.shownNativeCryptoCurrencyReceivedPrompt { |
||||
return shown |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
private var hasShownExceededThresholdPrompt: Bool { |
||||
if let shown = readState()?.backupState[wallet.address]?.shownNativeCryptoCurrencyDollarValueExceedThresholdPrompt { |
||||
return shown |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
private var isBackedUp: Bool { |
||||
if let backedUpTime = readState()?.backupState[wallet.address]?.lastBackedUpTime { |
||||
return true |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
private var isImported: Bool { |
||||
return readState()?.backupState[wallet.address]?.isImported ?? false |
||||
} |
||||
|
||||
private var canBackupWallet: Bool { |
||||
switch wallet.type { |
||||
case .real: |
||||
return true |
||||
case .watch: |
||||
return false |
||||
} |
||||
} |
||||
|
||||
private var isShowingExceededThresholdPrompt: Bool { |
||||
guard let prompt = readState()?.prompt[wallet.address] else { return false } |
||||
switch prompt { |
||||
case .nativeCryptoCurrencyDollarValueExceededThreshold: |
||||
return true |
||||
case .newWallet, .intervalPassed, .receivedNativeCryptoCurrency: |
||||
return false |
||||
} |
||||
} |
||||
|
||||
private var timeToShowIntervalPassedPrompt: Date? { |
||||
return readState()?.backupState[wallet.address]?.timeToShowIntervalPassedPrompt |
||||
} |
||||
|
||||
|
||||
private func readState() -> WalletsBackupState? { |
||||
return WalletsBackupState.load(fromUrl: fileUrl) |
||||
} |
||||
|
||||
private func writeState(_ state: WalletsBackupState) { |
||||
state.writeTo(url: fileUrl) |
||||
} |
||||
|
||||
private func updateState(block: (inout WalletsBackupState) -> ()) { |
||||
if var state = readState() { |
||||
block(&state) |
||||
writeState(state) |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension PromptBackupCoordinator: WalletCoordinatorDelegate { |
||||
func didFinish(with account: Wallet, in coordinator: WalletCoordinator) { |
||||
coordinator.navigationController.dismiss(animated: true, completion: nil) |
||||
removeCoordinator(coordinator) |
||||
finish() |
||||
extension PromptBackupCoordinator: PromptBackupWalletViewDelegate { |
||||
func viewControllerToShowBackupLaterAlert(forView view: PromptBackupWalletView) -> UIViewController? { |
||||
switch view { |
||||
case prominentPromptView: |
||||
return prominentPromptDelegate?.viewControllerToShowBackupLaterAlert |
||||
case subtlePromptView: |
||||
return subtlePromptDelegate?.viewControllerToShowBackupLaterAlert |
||||
default: |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func didChooseBackupLater(inView view: PromptBackupWalletView) { |
||||
remindLater() |
||||
showHideCurrentPrompt() |
||||
} |
||||
|
||||
func didFail(with error: Error, in coordinator: WalletCoordinator) { |
||||
coordinator.navigationController.dismiss(animated: true, completion: nil) |
||||
func didChooseBackup(inView view: PromptBackupWalletView) { |
||||
guard let nc = viewControllerToShowBackupLaterAlert(forView: view)?.navigationController else { return } |
||||
let coordinator = BackupCoordinator(navigationController: nc, keystore: keystore, account: .init(address: wallet.address)) |
||||
coordinator.delegate = self |
||||
coordinator.start() |
||||
addCoordinator(coordinator) |
||||
} |
||||
} |
||||
|
||||
extension PromptBackupCoordinator: BackupCoordinatorDelegate { |
||||
func didCancel(coordinator: BackupCoordinator) { |
||||
removeCoordinator(coordinator) |
||||
finish() |
||||
} |
||||
|
||||
func didCancel(in coordinator: WalletCoordinator) { |
||||
coordinator.navigationController.dismiss(animated: true, completion: nil) |
||||
func didFinish(account: EthereumAccount, in coordinator: BackupCoordinator) { |
||||
removeCoordinator(coordinator) |
||||
finish() |
||||
markBackupDone() |
||||
showHideCurrentPrompt() |
||||
} |
||||
} |
||||
|
@ -0,0 +1,79 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import BigInt |
||||
|
||||
struct WalletsBackupState: Codable { |
||||
enum Prompt { |
||||
case newWallet |
||||
case receivedNativeCryptoCurrency(BigInt) |
||||
case intervalPassed |
||||
case nativeCryptoCurrencyDollarValueExceededThreshold |
||||
} |
||||
|
||||
struct BackupState: Codable { |
||||
var shownNativeCryptoCurrencyReceivedPrompt = false |
||||
var timeToShowIntervalPassedPrompt: Date? |
||||
var shownNativeCryptoCurrencyDollarValueExceedThresholdPrompt = false |
||||
var lastBackedUpTime: Date? |
||||
var isImported: Bool |
||||
} |
||||
|
||||
var prompt = [AlphaWallet.Address: Prompt]() |
||||
var backupState = [AlphaWallet.Address: BackupState]() |
||||
|
||||
func writeTo(url: URL) { |
||||
let encoder = JSONEncoder() |
||||
let data = try! encoder.encode(self) |
||||
try? data.write(to: url) |
||||
} |
||||
|
||||
static func load(fromUrl url: URL) -> WalletsBackupState? { |
||||
guard let data = try? Data(contentsOf: url) else { return nil } |
||||
return try? JSONDecoder().decode(WalletsBackupState.self, from: data) |
||||
} |
||||
} |
||||
|
||||
extension WalletsBackupState.Prompt: Codable { |
||||
enum Key: CodingKey { |
||||
case rawValue |
||||
case associatedValue |
||||
} |
||||
|
||||
enum CodingError: Error { |
||||
case unknownValue |
||||
} |
||||
|
||||
init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: Key.self) |
||||
let rawValue = try container.decode(Int.self, forKey: .rawValue) |
||||
switch rawValue { |
||||
case 0: |
||||
self = .newWallet |
||||
case 1: |
||||
let nativeCryptoCurrency = try container.decode(BigInt.self, forKey: .associatedValue) |
||||
self = .receivedNativeCryptoCurrency(nativeCryptoCurrency) |
||||
case 2: |
||||
self = .intervalPassed |
||||
case 3: |
||||
self = .nativeCryptoCurrencyDollarValueExceededThreshold |
||||
default: |
||||
throw CodingError.unknownValue |
||||
} |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: Key.self) |
||||
switch self { |
||||
case .newWallet: |
||||
try container.encode(0, forKey: .rawValue) |
||||
case .receivedNativeCryptoCurrency(let nativeCryptoCurrency): |
||||
try container.encode(1, forKey: .rawValue) |
||||
try container.encode(nativeCryptoCurrency, forKey: .associatedValue) |
||||
case .intervalPassed: |
||||
try container.encode(2, forKey: .rawValue) |
||||
case .nativeCryptoCurrencyDollarValueExceededThreshold: |
||||
try container.encode(3, forKey: .rawValue) |
||||
} |
||||
} |
||||
} |
@ -1,86 +0,0 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import Foundation |
||||
import TrustKeystore |
||||
import UIKit |
||||
|
||||
protocol BackupViewControllerDelegate: class { |
||||
func didPressBackup(account: Account, in viewController: BackupViewController) |
||||
} |
||||
|
||||
class BackupViewController: UIViewController { |
||||
private let account: Account |
||||
lazy private var viewModel = BackupViewModel() |
||||
|
||||
weak var delegate: BackupViewControllerDelegate? |
||||
|
||||
init(account: Account) { |
||||
self.account = account |
||||
|
||||
super.init(nibName: nil, bundle: nil) |
||||
|
||||
let warningImageView = UIImageView() |
||||
warningImageView.translatesAutoresizingMaskIntoConstraints = false |
||||
warningImageView.image = R.image.backup_warning() |
||||
|
||||
let noBackupLabel = UILabel() |
||||
noBackupLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
noBackupLabel.text = viewModel.headlineText |
||||
noBackupLabel.font = Fonts.semibold(size: 24) |
||||
noBackupLabel.adjustsFontSizeToFitWidth = true |
||||
noBackupLabel.textColor = Colors.lightBlack |
||||
|
||||
let controlMoneyLabel = UILabel() |
||||
controlMoneyLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
controlMoneyLabel.text = R.string.localizable.exportControlYourMoneyLabelTitle() |
||||
controlMoneyLabel.numberOfLines = 0 |
||||
controlMoneyLabel.textAlignment = .center |
||||
controlMoneyLabel.textColor = Colors.darkGray |
||||
|
||||
let neverStoredLabel = UILabel() |
||||
neverStoredLabel.translatesAutoresizingMaskIntoConstraints = false |
||||
neverStoredLabel.text = R.string.localizable.exportNeverStoredLabelTitle() |
||||
neverStoredLabel.numberOfLines = 0 |
||||
neverStoredLabel.textAlignment = .center |
||||
neverStoredLabel.textColor = Colors.darkGray |
||||
|
||||
let backupButton = Button(size: .large, style: .solid) |
||||
backupButton.translatesAutoresizingMaskIntoConstraints = false |
||||
backupButton.setTitle(R.string.localizable.exportBackupButtonTitle(), for: .normal) |
||||
backupButton.addTarget(self, action: #selector(backup), for: .touchUpInside) |
||||
|
||||
let stackView = [ |
||||
warningImageView, |
||||
.spacer(), |
||||
noBackupLabel, |
||||
.spacer(height: 15), |
||||
controlMoneyLabel, |
||||
neverStoredLabel, |
||||
.spacer(height: 15), |
||||
backupButton, |
||||
].asStackView(axis: .vertical, spacing: 20, alignment: .center) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
|
||||
view.backgroundColor = .white |
||||
view.addSubview(stackView) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
stackView.topAnchor.constraint(greaterThanOrEqualTo: view.layoutGuide.topAnchor, constant: StyleLayout.sideMargin), |
||||
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), |
||||
stackView.leadingAnchor.constraint(equalTo: view.layoutGuide.leadingAnchor, constant: StyleLayout.sideMargin), |
||||
stackView.trailingAnchor.constraint(equalTo: view.layoutGuide.trailingAnchor, constant: -StyleLayout.sideMargin), |
||||
stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.layoutGuide.bottomAnchor, constant: -StyleLayout.sideMargin), |
||||
|
||||
backupButton.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), |
||||
backupButton.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), |
||||
]) |
||||
} |
||||
|
||||
@objc func backup() { |
||||
delegate?.didPressBackup(account: account, in: self) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
struct PromptBackupWalletAfterExceedingThresholdViewViewModel: PromptBackupWalletViewViewModel { |
||||
let walletAddress: AlphaWallet.Address |
||||
let dollarValueInUsd: Double |
||||
|
||||
var backgroundColor: UIColor { |
||||
return .init(red: 183, green: 80, blue: 70) |
||||
} |
||||
|
||||
var title: String { |
||||
return R.string.localizable.backupPromptAfterHittingThresholdTitle() |
||||
} |
||||
|
||||
var description: String { |
||||
let prettyAmount = CurrencyFormatter.formatter.string(from: NSNumber(value: dollarValueInUsd)) ?? "-" |
||||
return R.string.localizable.backupPromptAfterHittingThresholdDescription(prettyAmount) |
||||
} |
||||
|
||||
var backupButtonBackgroundColor: UIColor { |
||||
return UIColor(red: 119, green: 56, blue: 50) |
||||
} |
||||
} |
@ -0,0 +1,24 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
struct PromptBackupWalletAfterIntervalViewViewModel: PromptBackupWalletViewViewModel { |
||||
let walletAddress: AlphaWallet.Address |
||||
|
||||
var backgroundColor: UIColor { |
||||
return .init(red: 97, green: 103, blue: 123) |
||||
} |
||||
|
||||
var title: String { |
||||
return R.string.localizable.backupPromptAfterIntervalTitle() |
||||
} |
||||
|
||||
var description: String { |
||||
return R.string.localizable.backupPromptAfterIntervalDescription() |
||||
} |
||||
|
||||
var backupButtonBackgroundColor: UIColor { |
||||
return UIColor(red: 65, green: 71, blue: 89) |
||||
} |
||||
} |
@ -0,0 +1,28 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
import BigInt |
||||
|
||||
struct PromptBackupWalletAfterReceivingNativeCryptoCurrencyViewViewModel: PromptBackupWalletViewViewModel { |
||||
let walletAddress: AlphaWallet.Address |
||||
let nativeCryptoCurrency: BigInt |
||||
|
||||
var backgroundColor: UIColor { |
||||
return .init(red: 97, green: 103, blue: 123) |
||||
} |
||||
|
||||
var title: String { |
||||
let formatter = EtherNumberFormatter.short |
||||
let amount = formatter.string(from: nativeCryptoCurrency, decimals: 18) |
||||
return R.string.localizable.backupPromptAfterReceivingEtherTitle(amount) |
||||
} |
||||
|
||||
var description: String { |
||||
return R.string.localizable.backupPromptDescriptionWithoutAmount() |
||||
} |
||||
|
||||
var backupButtonBackgroundColor: UIColor { |
||||
return UIColor(red: 65, green: 71, blue: 89) |
||||
} |
||||
} |
@ -0,0 +1,24 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
struct PromptBackupWalletAfterWalletCreationViewViewModel: PromptBackupWalletViewViewModel { |
||||
let walletAddress: AlphaWallet.Address |
||||
|
||||
var backgroundColor: UIColor { |
||||
return .init(red: 183, green: 80, blue: 70) |
||||
} |
||||
|
||||
var title: String { |
||||
return R.string.localizable.backupPromptTitle() |
||||
} |
||||
|
||||
var description: String { |
||||
return R.string.localizable.backupPromptDescriptionWithoutAmount() |
||||
} |
||||
|
||||
var backupButtonBackgroundColor: UIColor { |
||||
return UIColor(red: 119, green: 56, blue: 50) |
||||
} |
||||
} |
@ -0,0 +1,75 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
protocol PromptBackupWalletViewViewModel { |
||||
var backgroundColor: UIColor { get } |
||||
var cornerRadius: CGFloat { get } |
||||
var titleFont: UIFont { get } |
||||
var titleColor: UIColor { get } |
||||
var title: String { get } |
||||
var descriptionFont: UIFont { get } |
||||
var descriptionColor: UIColor { get } |
||||
var description: String { get } |
||||
var backupButtonBackgroundColor: UIColor { get } |
||||
var backupButtonTitleColor: UIColor { get } |
||||
var backupButtonTitle: String { get } |
||||
var backupButtonTitleFont: UIFont { get } |
||||
var backupButtonImage: UIImage { get } |
||||
var backupButtonContentEdgeInsets: UIEdgeInsets { get } |
||||
var moreButtonImage: UIImage { get } |
||||
var moreButtonColor: UIColor { get } |
||||
var walletAddress: AlphaWallet.Address { get } |
||||
} |
||||
|
||||
extension PromptBackupWalletViewViewModel { |
||||
var cornerRadius: CGFloat { |
||||
return 20 |
||||
} |
||||
|
||||
var titleFont: UIFont { |
||||
return Fonts.regular(size: 22)! |
||||
} |
||||
|
||||
var titleColor: UIColor { |
||||
return Colors.appWhite |
||||
} |
||||
|
||||
var descriptionFont: UIFont { |
||||
return Fonts.regular(size: 13)! |
||||
} |
||||
|
||||
var descriptionColor: UIColor { |
||||
return Colors.appWhite |
||||
} |
||||
|
||||
var backupButtonTitleColor: UIColor { |
||||
return Colors.appWhite |
||||
} |
||||
|
||||
var backupButtonTitleFont: UIFont { |
||||
return Fonts.semibold(size: 16)! |
||||
} |
||||
|
||||
var backupButtonImage: UIImage { |
||||
return R.image.toolbarForward()! |
||||
} |
||||
|
||||
var backupButtonContentEdgeInsets: UIEdgeInsets { |
||||
return .init(top: 7, left: 21, bottom: 7, right: 21) |
||||
} |
||||
|
||||
var moreButtonImage: UIImage { |
||||
return R.image.toolbarMenu()! |
||||
} |
||||
|
||||
var moreButtonColor: UIColor { |
||||
return Colors.appWhite |
||||
} |
||||
|
||||
var backupButtonTitle: String { |
||||
let firstFewCharactersOfWalletAddress = walletAddress.eip55String.substring(with: Range(uncheckedBounds: (0, 4))) |
||||
return "\(R.string.localizable.backupPromptBackupButtonTitle().uppercased()) \(firstFewCharactersOfWalletAddress) " |
||||
} |
||||
} |
@ -0,0 +1,106 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
protocol PromptBackupWalletViewDelegate: class { |
||||
func viewControllerToShowBackupLaterAlert(forView view: PromptBackupWalletView) -> UIViewController? |
||||
func didChooseBackupLater(inView view: PromptBackupWalletView) |
||||
func didChooseBackup(inView view: PromptBackupWalletView) |
||||
} |
||||
|
||||
class PromptBackupWalletView: UIView { |
||||
private let titleLabel = UILabel() |
||||
private let descriptionLabel = UILabel() |
||||
private let backupButton = UIButton(type: .system) |
||||
private let remindMeLaterButton = UIButton(type: .system) |
||||
private let viewModel: PromptBackupWalletViewViewModel |
||||
|
||||
weak var delegate: PromptBackupWalletViewDelegate? |
||||
|
||||
init(viewModel: PromptBackupWalletViewViewModel) { |
||||
self.viewModel = viewModel |
||||
super.init(frame: .zero) |
||||
|
||||
backupButton.addTarget(self, action: #selector(backup), for: .touchUpInside) |
||||
backupButton.setContentHuggingPriority(.required, for: .horizontal) |
||||
|
||||
remindMeLaterButton.addTarget(self, action: #selector(remindMeLater), for: .touchUpInside) |
||||
remindMeLaterButton.setContentHuggingPriority(.required, for: .horizontal) |
||||
|
||||
let row0 = [titleLabel, remindMeLaterButton].asStackView(axis: .horizontal) |
||||
let stackView = [ |
||||
row0, |
||||
UIView.spacer(height: 10), |
||||
descriptionLabel, |
||||
UIView.spacer(height: 10), |
||||
backupButton, |
||||
].asStackView(axis: .vertical, alignment: .leading) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(stackView) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
row0.widthAnchor.constraint(equalTo: stackView.widthAnchor), |
||||
|
||||
descriptionLabel.widthAnchor.constraint(equalTo: backupButton.widthAnchor, constant: 30), |
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: topAnchor, constant: 20), |
||||
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20), |
||||
]) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
func configure() { |
||||
backgroundColor = viewModel.backgroundColor |
||||
cornerRadius = viewModel.cornerRadius |
||||
|
||||
titleLabel.font = viewModel.titleFont |
||||
titleLabel.textColor = viewModel.titleColor |
||||
titleLabel.text = viewModel.title |
||||
//For small screens |
||||
titleLabel.adjustsFontSizeToFitWidth = true |
||||
|
||||
remindMeLaterButton.setImage(viewModel.moreButtonImage, for: .normal) |
||||
remindMeLaterButton.tintColor = viewModel.moreButtonColor |
||||
|
||||
descriptionLabel.font = viewModel.descriptionFont |
||||
descriptionLabel.textColor = viewModel.descriptionColor |
||||
descriptionLabel.text = viewModel.description |
||||
descriptionLabel.numberOfLines = 0 |
||||
|
||||
backupButton.tintColor = viewModel.backupButtonTitleColor |
||||
backupButton.titleLabel?.font = viewModel.backupButtonTitleFont |
||||
backupButton.setBackgroundColor(viewModel.backupButtonBackgroundColor, forState: .normal) |
||||
backupButton.setTitleColor(viewModel.backupButtonTitleColor, for: .normal) |
||||
backupButton.setTitle(viewModel.backupButtonTitle, for: .normal) |
||||
backupButton.setImage(viewModel.backupButtonImage, for: .normal) |
||||
backupButton.contentEdgeInsets = viewModel.backupButtonContentEdgeInsets |
||||
swapButtonTextAndImage(backupButton) |
||||
} |
||||
|
||||
@objc private func backup() { |
||||
delegate?.didChooseBackup(inView: self) |
||||
} |
||||
|
||||
@objc private func remindMeLater() { |
||||
delegate?.viewControllerToShowBackupLaterAlert(forView: self)?.confirm(message: R.string.localizable.backupPromptBackupRemindLater()) { result in |
||||
switch result { |
||||
case .success: |
||||
self.delegate?.didChooseBackupLater(inView: self) |
||||
case .failure: |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func swapButtonTextAndImage(_ button: UIButton) { |
||||
button.titleLabel?.transform = CGAffineTransform(scaleX: -1.0, y: 1.0) |
||||
button.imageView?.transform = CGAffineTransform(scaleX: -1.0, y: 1.0) |
||||
button.transform = CGAffineTransform(scaleX: -1.0, y: 1.0) |
||||
} |
||||
} |
@ -0,0 +1,8 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
|
||||
protocol CoordinatorThatEnds: Coordinator { |
||||
func endUserInterface(animated: Bool) |
||||
func end() |
||||
} |
@ -0,0 +1,813 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import Foundation |
||||
import LocalAuthentication |
||||
import BigInt |
||||
import KeychainSwift |
||||
import Result |
||||
import TrustWalletCore |
||||
|
||||
enum EtherKeystoreError: LocalizedError { |
||||
case protectionDisabled |
||||
} |
||||
|
||||
///We use ECDSA keys (created and stored in the Secure Enclave), achieving symmetric encryption based on Diffie-Hellman to encrypt the HD wallet seed and raw private keys and store the ciphertext in the keychain. |
||||
/// |
||||
///There are 2 sets of (ECDSA key and ciphertext) for each Ethereum raw private key or HD wallet seed. 1 set is stored requiring user presence for access and the other doesn't. The second set is needed to ensure the user has does not lose access to the Ethereum raw private key (or HD wallet seed) when they delete their iOS passcode. Once the user has verified that they have backed up their wallet, they can choose to elevate the security of their wallet which deletes the set of (ECDSA key and ciphertext) that do not require user-presence. |
||||
/// |
||||
///Technically, having 2 sets of (ECDSA key and ciphertext) for each Ethereum raw private key or HD wallet seed may not be required for iOS. But it is done: |
||||
///(A) to be confident that we don't cause the user to lose access to their wallets and |
||||
///(B) to be consistent with Android's UI and implementation which seems like users will lose access to the data (i.e wallet) which requires user presence if the equivalent of their iOS passcode/biometrics is disabled/deleted |
||||
// swiftlint:disable type_body_length |
||||
open class EtherKeystore: Keystore { |
||||
private struct Keys { |
||||
static let recentlyUsedAddress: String = "recentlyUsedAddress" |
||||
static let watchAddresses = "watchAddresses" |
||||
static let ethereumAddressesWithPrivateKeys = "ethereumAddressesWithPrivateKeys" |
||||
static let ethereumAddressesWithSeed = "ethereumAddressesWithSeed" |
||||
static let ethereumAddressesProtectedByUserPresence = "ethereumAddressesProtectedByUserPresence" |
||||
static let ethereumRawPrivateKeyUserPresenceNotRequiredPrefix = "ethereumRawPrivateKeyUserPresenceNotRequired-" |
||||
static let ethereumSeedUserPresenceNotRequiredPrefix = "ethereumSeedUserPresenceNotRequired-" |
||||
static let ethereumRawPrivateKeyUserPresenceRequiredPrefix = "ethereumRawPrivateKeyUserPresenceRequired-" |
||||
static let ethereumSeedUserPresenceRequiredPrefix = "ethereumSeedUserPresenceRequired-" |
||||
//These aren't actually the label for the encryption key, but rather, the label for the ECDSA keys that will be used to generate the AES encryption keys since iOS Secure Enclave only supports ECDSA and not AES |
||||
static let encryptionKeyForSeedUserPresenceRequiredPrefix = "encryptionKeyForSeedUserPresenceRequired-" |
||||
static let encryptionKeyForPrivateKeyUserPresenceRequiredPrefix = "encryptionKeyForPrivateKeyUserPresenceRequired-" |
||||
static let encryptionKeyForSeedUserPresenceNotRequiredPrefix = "encryptionKeyForSeedUserPresenceNotRequired-" |
||||
static let encryptionKeyForPrivateKeyUserPresenceNotRequiredPrefix = "encryptionKeyForPrivateKeyUserPresenceNotRequired-" |
||||
} |
||||
|
||||
enum WalletSeedOrKey { |
||||
case key(Data) |
||||
case seed(String) |
||||
case seedPhrase(String) |
||||
case userCancelled |
||||
case notFound |
||||
case otherFailure |
||||
} |
||||
|
||||
private let emptyPassphrase = "" |
||||
private let keychain: KeychainSwift |
||||
private let defaultKeychainAccessUserPresenceRequired: KeychainSwiftAccessOptions = .accessibleWhenUnlockedThisDeviceOnly(userPresenceRequired: true) |
||||
private let defaultKeychainAccessUserPresenceNotRequired: KeychainSwiftAccessOptions = .accessibleWhenUnlockedThisDeviceOnly(userPresenceRequired: false) |
||||
private let userDefaults: UserDefaults |
||||
|
||||
private var watchAddresses: [String] { |
||||
set { |
||||
let data = NSKeyedArchiver.archivedData(withRootObject: newValue) |
||||
return userDefaults.set(data, forKey: Keys.watchAddresses) |
||||
} |
||||
get { |
||||
guard let data = userDefaults.data(forKey: Keys.watchAddresses) else { |
||||
return [] |
||||
} |
||||
return NSKeyedUnarchiver.unarchiveObject(with: data) as? [String] ?? [] |
||||
} |
||||
} |
||||
|
||||
private var ethereumAddressesWithPrivateKeys: [String] { |
||||
set { |
||||
let data = NSKeyedArchiver.archivedData(withRootObject: newValue) |
||||
return userDefaults.set(data, forKey: Keys.ethereumAddressesWithPrivateKeys) |
||||
} |
||||
get { |
||||
guard let data = userDefaults.data(forKey: Keys.ethereumAddressesWithPrivateKeys) else { |
||||
return [] |
||||
} |
||||
return NSKeyedUnarchiver.unarchiveObject(with: data) as? [String] ?? [] |
||||
} |
||||
} |
||||
|
||||
private var ethereumAddressesWithSeed: [String] { |
||||
set { |
||||
let data = NSKeyedArchiver.archivedData(withRootObject: newValue) |
||||
return userDefaults.set(data, forKey: Keys.ethereumAddressesWithSeed) |
||||
} |
||||
get { |
||||
guard let data = userDefaults.data(forKey: Keys.ethereumAddressesWithSeed) else { |
||||
return [] |
||||
} |
||||
return NSKeyedUnarchiver.unarchiveObject(with: data) as? [String] ?? [] |
||||
} |
||||
} |
||||
|
||||
private var ethereumAddressesProtectedByUserPresence: [String] { |
||||
set { |
||||
let data = NSKeyedArchiver.archivedData(withRootObject: newValue) |
||||
return userDefaults.set(data, forKey: Keys.ethereumAddressesProtectedByUserPresence) |
||||
} |
||||
get { |
||||
guard let data = userDefaults.data(forKey: Keys.ethereumAddressesProtectedByUserPresence) else { |
||||
return [] |
||||
} |
||||
return NSKeyedUnarchiver.unarchiveObject(with: data) as? [String] ?? [] |
||||
} |
||||
} |
||||
|
||||
//i.e if passcode is enabled. Face ID/Touch ID wouldn't work without passcode being enabled and we can't write to the keychain or generate a key in secure enclave when passcode is disabled |
||||
var isUserPresenceCheckPossible: Bool { |
||||
let authContext = LAContext() |
||||
return authContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) |
||||
} |
||||
|
||||
var hasWallets: Bool { |
||||
return !wallets.isEmpty |
||||
} |
||||
|
||||
var wallets: [Wallet] { |
||||
let watchAddresses = self.watchAddresses.compactMap { AlphaWallet.Address(string: $0) }.map { Wallet(type: .watch($0)) } |
||||
let addressesWithPrivateKeys = ethereumAddressesWithPrivateKeys.compactMap { AlphaWallet.Address(string: $0) }.map { Wallet(type: .real(.init(address: $0))) } |
||||
let addressesWithSeed = ethereumAddressesWithSeed.compactMap { AlphaWallet.Address(string: $0) }.map { Wallet(type: .real(.init(address: $0))) } |
||||
return addressesWithSeed + addressesWithPrivateKeys + watchAddresses |
||||
} |
||||
|
||||
var hasMigratedFromKeystoreFiles: Bool { |
||||
return userDefaults.data(forKey: Keys.ethereumAddressesWithPrivateKeys) != nil |
||||
} |
||||
|
||||
var recentlyUsedWallet: Wallet? { |
||||
set { |
||||
keychain.set(newValue?.address.eip55String ?? "", forKey: Keys.recentlyUsedAddress, withAccess: defaultKeychainAccessUserPresenceNotRequired) |
||||
} |
||||
get { |
||||
guard let address = keychain.get(Keys.recentlyUsedAddress) else { |
||||
return nil |
||||
} |
||||
return wallets.filter { |
||||
$0.address.sameContract(as: address) |
||||
}.first |
||||
} |
||||
} |
||||
|
||||
//TODO improve |
||||
static var current: Wallet? { |
||||
do { |
||||
return try EtherKeystore().recentlyUsedWallet |
||||
} catch { |
||||
return .none |
||||
} |
||||
} |
||||
|
||||
public init( |
||||
keychain: KeychainSwift = KeychainSwift(keyPrefix: Constants.keychainKeyPrefix), |
||||
userDefaults: UserDefaults = UserDefaults.standard |
||||
) throws { |
||||
if !UIApplication.shared.isProtectedDataAvailable { |
||||
throw EtherKeystoreError.protectionDisabled |
||||
} |
||||
self.keychain = keychain |
||||
self.keychain.synchronizable = false |
||||
self.userDefaults = userDefaults |
||||
} |
||||
|
||||
// Async |
||||
func createAccount(completion: @escaping (Result<EthereumAccount, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { |
||||
return |
||||
} |
||||
let result = strongSelf.createAccount() |
||||
DispatchQueue.main.async { |
||||
completion(result) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func importWallet(type: ImportType, completion: @escaping (Result<Wallet, KeystoreError>) -> Void) { |
||||
let results = importWallet(type: type) |
||||
switch results { |
||||
case .success(let wallet): |
||||
//TODO not the best way to do this but let's see if there's a better way to inform the coordinator that a wallet has been imported to avoid it being prompted for back |
||||
PromptBackupCoordinator(keystore: self, wallet: wallet, config: .init()).markWalletAsImported() |
||||
case .failure: |
||||
break |
||||
} |
||||
completion(results) |
||||
} |
||||
|
||||
func importWallet(type: ImportType) -> Result<Wallet, KeystoreError> { |
||||
switch type { |
||||
case .keystore(let json, let password): |
||||
guard let keystore = try? LegacyFileBasedKeystore() else { |
||||
return .failure(.failedToExportPrivateKey) |
||||
} |
||||
let result = keystore.getPrivateKeyFromKeystoreFile(json: json, password: password) |
||||
switch result { |
||||
case .success(let privateKey): |
||||
return importWallet(type: .privateKey(privateKey: privateKey)) |
||||
case .failure(let error): |
||||
return .failure(error) |
||||
} |
||||
case .privateKey(let privateKey): |
||||
let address = AlphaWallet.Address(fromPrivateKey: privateKey) |
||||
let hasEthereumAddressAlready = wallets.map({ $0.address }).contains { |
||||
$0.sameContract(as: address) |
||||
} |
||||
guard !hasEthereumAddressAlready else { |
||||
return .failure(.duplicateAccount) |
||||
} |
||||
if isUserPresenceCheckPossible { |
||||
let isSuccessful = savePrivateKeyForNonHdWallet(privateKey, forAccount: .init(address: address), withUserPresence: false) |
||||
guard isSuccessful else { return .failure(.failedToCreateWallet) } |
||||
let _ = savePrivateKeyForNonHdWallet(privateKey, forAccount: .init(address: address), withUserPresence: true) |
||||
} else { |
||||
let isSuccessful = savePrivateKeyForNonHdWallet(privateKey, forAccount: .init(address: address), withUserPresence: false) |
||||
guard isSuccessful else { return .failure(.failedToCreateWallet) } |
||||
} |
||||
addToListOfEthereumAddressesWithPrivateKeys(address) |
||||
return .success(Wallet(type: .real(.init(address: address)))) |
||||
case .mnemonic(let mnemonic, _): |
||||
let mnemonicString = mnemonic.joined(separator: " ") |
||||
let wallet = HDWallet(mnemonic: mnemonicString, passphrase: emptyPassphrase) |
||||
let privateKey = derivePrivateKeyOfAccount0(fromHdWallet: wallet) |
||||
let address = AlphaWallet.Address(fromPrivateKey: privateKey) |
||||
let hasEthereumAddressAlready = wallets.map({ $0.address }).contains { |
||||
$0.sameContract(as: address) |
||||
} |
||||
guard !hasEthereumAddressAlready else { |
||||
return .failure(.duplicateAccount) |
||||
} |
||||
let seed = HDWallet.computeSeedWithChecksum(fromSeedPhrase: mnemonicString) |
||||
if isUserPresenceCheckPossible { |
||||
let isSuccessful = saveSeedForHdWallet(seed, forAccount: .init(address: address), withUserPresence: false) |
||||
guard isSuccessful else { return .failure(.failedToCreateWallet) } |
||||
let _ = saveSeedForHdWallet(seed, forAccount: .init(address: address), withUserPresence: true) |
||||
} else { |
||||
let isSuccessful = saveSeedForHdWallet(seed, forAccount: .init(address: address), withUserPresence: false) |
||||
guard isSuccessful else { return .failure(.failedToCreateWallet) } |
||||
} |
||||
addToListOfEthereumAddressesWithSeed(address) |
||||
return .success(Wallet(type: .real(.init(address: address)))) |
||||
case .watch(let address): |
||||
guard !watchAddresses.contains(where: { address.sameContract(as: $0) }) else { |
||||
return .failure(.duplicateAccount) |
||||
} |
||||
watchAddresses = [watchAddresses, [address.eip55String]].flatMap { |
||||
$0 |
||||
} |
||||
return .success(Wallet(type: .watch(address))) |
||||
} |
||||
} |
||||
|
||||
private func addToListOfEthereumAddressesWithPrivateKeys(_ address: AlphaWallet.Address) { |
||||
let updatedOwnedAddresses = Array(Set(ethereumAddressesWithPrivateKeys + [address.eip55String])) |
||||
ethereumAddressesWithPrivateKeys = updatedOwnedAddresses |
||||
} |
||||
|
||||
private func addToListOfEthereumAddressesWithSeed(_ address: AlphaWallet.Address) { |
||||
let updated = Array(Set(ethereumAddressesWithSeed + [address.eip55String])) |
||||
ethereumAddressesWithSeed = updated |
||||
} |
||||
|
||||
private func addToListOfEthereumAddressesProtectedByUserPresence(_ address: AlphaWallet.Address) { |
||||
let updated = Array(Set(ethereumAddressesProtectedByUserPresence + [address.eip55String])) |
||||
ethereumAddressesProtectedByUserPresence = updated |
||||
} |
||||
|
||||
func createAccount() -> Result<EthereumAccount, KeystoreError> { |
||||
let strength = Int32(128) |
||||
let newHdWallet = HDWallet(strength: strength, passphrase: emptyPassphrase) |
||||
let mnemonic = newHdWallet.mnemonic.split(separator: " ").map { |
||||
String($0) |
||||
} |
||||
let result = importWallet(type: .mnemonic(words: mnemonic, password: emptyPassphrase)) |
||||
switch result { |
||||
case .success(let wallet): |
||||
return .success(.init(address: wallet.address)) |
||||
case .failure(let error): |
||||
return .failure(.failedToCreateWallet) |
||||
} |
||||
} |
||||
|
||||
private func derivePrivateKeyOfAccount0(fromHdWallet wallet: HDWallet) -> Data { |
||||
let firstAccountIndex = UInt32(0) |
||||
let externalChangeConstant = UInt32(0) |
||||
let addressIndex = UInt32(0) |
||||
let privateKey = wallet.getKey(purpose: .bip44, coin: .ethereum, account: firstAccountIndex, change: externalChangeConstant, address: addressIndex) |
||||
return privateKey.data |
||||
} |
||||
|
||||
func exportRawPrivateKeyForNonHdWalletForBackup(forAccount account: EthereumAccount, newPassword: String, completion: @escaping (Result<String, KeystoreError>) -> Void) { |
||||
let key: Data |
||||
switch getPrivateKeyFromNonHdWallet(forAccount: account, prompt: R.string.localizable.keystoreAccessKeyNonHdBackup(), withUserPresence: isUserPresenceCheckPossible) { |
||||
case .seed, .seedPhrase: |
||||
//Not possible |
||||
return completion(.failure(.failedToExportPrivateKey)) |
||||
case .key(let k): |
||||
key = k |
||||
case .userCancelled: |
||||
return completion(.failure(.userCancelled)) |
||||
case .notFound, .otherFailure: |
||||
return completion(.failure(.accountMayNeedImportingAgainOrEnablePasscode)) |
||||
} |
||||
//Careful to not replace the if-let with a flatMap(). Because the value is a Result and it has flatMap() defined to "resolve" only when it's .success |
||||
if let result = (try? LegacyFileBasedKeystore())?.export(privateKey: key, newPassword: newPassword) { |
||||
completion(result) |
||||
} else { |
||||
completion(.failure(.failedToExportPrivateKey)) |
||||
} |
||||
} |
||||
|
||||
func exportSeedPhraseOfHdWallet(forAccount account: EthereumAccount, reason: KeystoreExportReason, completion: @escaping (Result<String, KeystoreError>) -> Void) { |
||||
let seedPhrase = getSeedPhraseForHdWallet(forAccount: account, prompt: reason.prompt, withUserPresence: isUserPresenceCheckPossible) |
||||
switch seedPhrase { |
||||
case .seedPhrase(let seedPhrase): |
||||
completion(.success(seedPhrase)) |
||||
case .seed, .key: |
||||
completion(.failure(.failedToExportSeed)) |
||||
case .userCancelled: |
||||
completion(.failure(.userCancelled)) |
||||
case .notFound, .otherFailure: |
||||
completion(.failure(.failedToExportSeed)) |
||||
} |
||||
} |
||||
|
||||
func verifySeedPhraseOfHdWallet(_ inputSeedPhrase: String, forAccount account: EthereumAccount, completion: @escaping (Result<Bool, KeystoreError>) -> Void) { |
||||
switch getSeedPhraseForHdWallet(forAccount: account, prompt: R.string.localizable.keystoreAccessKeyHdVerify(), withUserPresence: isUserPresenceCheckPossible) { |
||||
case .seedPhrase(let actualSeedPhrase): |
||||
let matched = inputSeedPhrase.lowercased() == actualSeedPhrase.lowercased() |
||||
completion(.success(matched)) |
||||
case .seed, .key: |
||||
completion(.failure(.failedToExportSeed)) |
||||
case .userCancelled: |
||||
completion(.failure(.userCancelled)) |
||||
case .notFound, .otherFailure: |
||||
completion(.failure(.failedToExportSeed)) |
||||
} |
||||
} |
||||
|
||||
@discardableResult func delete(wallet: Wallet) -> Result<Void, KeystoreError> { |
||||
switch wallet.type { |
||||
case .real(let account): |
||||
//TODO not the best way to do this but let's see if there's a better way to inform the coordinator that a wallet has been deleted |
||||
PromptBackupCoordinator(keystore: self, wallet: wallet, config: .init()).deleteWallet() |
||||
|
||||
removeAccountFromBookkeeping(account) |
||||
deleteKeysAndSeedCipherTextFromKeychain(forAccount: account) |
||||
deletePrivateKeysFromSecureEnclave(forAccount: account) |
||||
case .watch(let address): |
||||
removeAccountFromBookkeeping(.init(address: address)) |
||||
} |
||||
(try? LegacyFileBasedKeystore())?.delete(wallet: wallet) |
||||
return .success(()) |
||||
} |
||||
|
||||
private func deletePrivateKeysFromSecureEnclave(forAccount account: EthereumAccount) { |
||||
let secureEnclave = SecureEnclave() |
||||
secureEnclave.deletePrivateKeys(withName: encryptionKeyForPrivateKeyLabel(fromAccount: account, withUserPresence: true)) |
||||
secureEnclave.deletePrivateKeys(withName: encryptionKeyForPrivateKeyLabel(fromAccount: account, withUserPresence: false)) |
||||
secureEnclave.deletePrivateKeys(withName: encryptionKeyForSeedLabel(fromAccount: account, withUserPresence: true)) |
||||
secureEnclave.deletePrivateKeys(withName: encryptionKeyForSeedLabel(fromAccount: account, withUserPresence: false)) |
||||
} |
||||
|
||||
private func deleteKeysAndSeedCipherTextFromKeychain(forAccount account: EthereumAccount) { |
||||
keychain.delete("\(Keys.ethereumRawPrivateKeyUserPresenceNotRequiredPrefix)\(account.address.eip55String)") |
||||
keychain.delete("\(Keys.ethereumRawPrivateKeyUserPresenceRequiredPrefix)\(account.address.eip55String)") |
||||
keychain.delete("\(Keys.ethereumSeedUserPresenceNotRequiredPrefix)\(account.address.eip55String)") |
||||
keychain.delete("\(Keys.ethereumSeedUserPresenceRequiredPrefix)\(account.address.eip55String)") |
||||
} |
||||
|
||||
private func removeAccountFromBookkeeping(_ account: EthereumAccount) { |
||||
ethereumAddressesWithPrivateKeys = ethereumAddressesWithPrivateKeys.filter { $0 != account.address.eip55String } |
||||
ethereumAddressesWithSeed = ethereumAddressesWithSeed.filter { $0 != account.address.eip55String } |
||||
ethereumAddressesProtectedByUserPresence = ethereumAddressesProtectedByUserPresence.filter { $0 != account.address.eip55String } |
||||
watchAddresses = watchAddresses.filter { $0 != account.address.eip55String } |
||||
} |
||||
|
||||
func delete(wallet: Wallet, completion: @escaping (Result<Void, KeystoreError>) -> Void) { |
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
||||
guard let strongSelf = self else { |
||||
return |
||||
} |
||||
let result = strongSelf.delete(wallet: wallet) |
||||
DispatchQueue.main.async { |
||||
completion(result) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func isHdWallet(account: EthereumAccount) -> Bool { |
||||
return ethereumAddressesWithSeed.contains(account.address.eip55String) |
||||
} |
||||
|
||||
func isHdWallet(wallet: Wallet) -> Bool { |
||||
switch wallet.type { |
||||
case .real(let account): |
||||
return ethereumAddressesWithSeed.contains(account.address.eip55String) |
||||
case .watch: |
||||
return false |
||||
} |
||||
} |
||||
|
||||
func isKeystore(wallet: Wallet) -> Bool { |
||||
switch wallet.type { |
||||
case .real(let account): |
||||
return ethereumAddressesWithPrivateKeys.contains(account.address.eip55String) |
||||
case .watch: |
||||
return false |
||||
} |
||||
} |
||||
|
||||
func isWatched(wallet: Wallet) -> Bool { |
||||
switch wallet.type { |
||||
case .real: |
||||
return false |
||||
case .watch(let address): |
||||
return watchAddresses.contains(address.eip55String) |
||||
} |
||||
} |
||||
|
||||
func isProtectedByUserPresence(account: EthereumAccount) -> Bool { |
||||
return ethereumAddressesProtectedByUserPresence.contains(account.address.eip55String) |
||||
} |
||||
|
||||
func signPersonalMessage(_ message: Data, for account: EthereumAccount) -> Result<Data, KeystoreError> { |
||||
let prefix = "\u{19}Ethereum Signed Message:\n\(message.count)".data(using: .utf8)! |
||||
return signMessage(prefix + message, for: account) |
||||
} |
||||
|
||||
func signHash(_ hash: Data, for account: EthereumAccount) -> Result<Data, KeystoreError> { |
||||
let key = getPrivateKeyForSigning(forAccount: account) |
||||
switch key { |
||||
case .seed, .seedPhrase: |
||||
return .failure(.failedToExportPrivateKey) |
||||
case .key(let key): |
||||
do { |
||||
var data = try EthereumSigner().sign(hash: hash, withPrivateKey: key) |
||||
// TODO: Make it configurable, instead of overriding last byte. |
||||
data[64] += 27 |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
case .userCancelled: |
||||
return .failure(.userCancelled) |
||||
case .notFound, .otherFailure: |
||||
return .failure(.accountMayNeedImportingAgainOrEnablePasscode) |
||||
} |
||||
} |
||||
|
||||
func signTypedMessage(_ datas: [EthTypedData], for account: EthereumAccount) -> Result<Data, KeystoreError> { |
||||
let schemas = datas.map { $0.schemaData }.reduce(Data(), { $0 + $1 }).sha3(.keccak256) |
||||
let values = datas.map { $0.typedData }.reduce(Data(), { $0 + $1 }).sha3(.keccak256) |
||||
let combined = (schemas + values).sha3(.keccak256) |
||||
return signHash(combined, for: account) |
||||
} |
||||
|
||||
func signMessage(_ message: Data, for account: EthereumAccount) -> Result<Data, KeystoreError> { |
||||
return signHash(message.sha3(.keccak256), for: account) |
||||
} |
||||
|
||||
func signMessageBulk(_ data: [Data], for account: EthereumAccount) -> Result<[Data], KeystoreError> { |
||||
switch getPrivateKeyForSigning(forAccount: account) { |
||||
case .seed, .seedPhrase: |
||||
return .failure(.failedToExportPrivateKey) |
||||
case .key(let key): |
||||
do { |
||||
var messageHashes = [Data]() |
||||
for i in 0...data.count - 1 { |
||||
let hash = data[i].sha3(.keccak256) |
||||
messageHashes.append(hash) |
||||
} |
||||
var data = try EthereumSigner().signHashes(messageHashes, withPrivateKey: key) |
||||
// TODO: Make it configurable, instead of overriding last byte. |
||||
for i in 0...data.count - 1 { |
||||
data[i][64] += 27 |
||||
} |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
case .userCancelled: |
||||
return .failure(.userCancelled) |
||||
case .notFound, .otherFailure: |
||||
return .failure(.accountMayNeedImportingAgainOrEnablePasscode) |
||||
} |
||||
} |
||||
|
||||
func signMessageData(_ message: Data?, for account: EthereumAccount) -> Result<Data, KeystoreError> { |
||||
guard let hash = message?.sha3(.keccak256) else { return .failure(KeystoreError.failedToSignMessage) } |
||||
switch getPrivateKeyForSigning(forAccount: account) { |
||||
case .seed, .seedPhrase: |
||||
return .failure(.failedToExportPrivateKey) |
||||
case .key(let key): |
||||
do { |
||||
var data = try EthereumSigner().sign(hash: hash, withPrivateKey: key) |
||||
data[64] += 27 |
||||
return .success(data) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToSignMessage) |
||||
} |
||||
case .userCancelled: |
||||
return .failure(.userCancelled) |
||||
case .notFound, .otherFailure: |
||||
return .failure(.accountMayNeedImportingAgainOrEnablePasscode) |
||||
} |
||||
} |
||||
|
||||
func signTransaction(_ transaction: UnsignedTransaction) -> Result<Data, KeystoreError> { |
||||
let signer: Signer |
||||
if transaction.server.chainID == 0 { |
||||
signer = HomesteadSigner() |
||||
} else { |
||||
signer = EIP155Signer(server: transaction.server) |
||||
} |
||||
|
||||
do { |
||||
let hash = signer.hash(transaction: transaction) |
||||
switch getPrivateKeyForSigning(forAccount: transaction.account) { |
||||
case .seed, .seedPhrase: |
||||
return .failure(.failedToExportPrivateKey) |
||||
case .key(let key): |
||||
let signature = try EthereumSigner().sign(hash: hash, withPrivateKey: key) |
||||
let (r, s, v) = signer.values(transaction: transaction, signature: signature) |
||||
let data = RLP.encode([ |
||||
transaction.nonce, |
||||
transaction.gasPrice, |
||||
transaction.gasLimit, |
||||
transaction.to?.data ?? Data(), |
||||
transaction.value, |
||||
transaction.data, |
||||
v, r, s, |
||||
])! |
||||
return .success(data) |
||||
case .userCancelled: |
||||
return .failure(.userCancelled) |
||||
case .notFound, .otherFailure: |
||||
return .failure(.accountMayNeedImportingAgainOrEnablePasscode) |
||||
} |
||||
} catch { |
||||
return .failure(.failedToSignTransaction) |
||||
} |
||||
} |
||||
|
||||
func getAccount(for address: AlphaWallet.Address) -> EthereumAccount? { |
||||
return .init(address: address) |
||||
} |
||||
|
||||
private func getPrivateKeyForSigning(forAccount account: EthereumAccount) -> WalletSeedOrKey { |
||||
let prompt = R.string.localizable.keystoreAccessKeySign() |
||||
if isHdWallet(account: account) { |
||||
let seed = getSeedForHdWallet(forAccount: account, prompt: prompt, withUserPresence: isUserPresenceCheckPossible) |
||||
switch seed { |
||||
case .seed(let seed): |
||||
let wallet = HDWallet(seed: seed, passphrase: emptyPassphrase) |
||||
let privateKey = derivePrivateKeyOfAccount0(fromHdWallet: wallet) |
||||
return .key(privateKey) |
||||
case .key, .seedPhrase: |
||||
//Not possible |
||||
return seed |
||||
case .userCancelled, .notFound, .otherFailure: |
||||
return seed |
||||
} |
||||
} else { |
||||
return getPrivateKeyFromNonHdWallet(forAccount: account, prompt: prompt, withUserPresence: isUserPresenceCheckPossible) |
||||
} |
||||
} |
||||
|
||||
private func getPrivateKeyFromNonHdWallet(forAccount account: EthereumAccount, prompt: String, withUserPresence: Bool, shouldWriteWithUserPresenceIfNotFound: Bool = true) -> WalletSeedOrKey { |
||||
let prefix: String |
||||
if withUserPresence { |
||||
prefix = Keys.ethereumRawPrivateKeyUserPresenceRequiredPrefix |
||||
} else { |
||||
prefix = Keys.ethereumRawPrivateKeyUserPresenceNotRequiredPrefix |
||||
} |
||||
let context = createContext() |
||||
let data = keychain.getData("\(prefix)\(account.address.eip55String)", prompt: prompt, withContext: context) |
||||
.flatMap { decryptPrivateKey(fromCipherTextData: $0, forAccount: account, withUserPresence: withUserPresence, withContext: context) } |
||||
|
||||
//We copy the record that doesn't require user-presence make a new one which requires user-presence and read from that. We don't want to read the one without user-presence unless absolutely necessary (e.g user has disabled passcode) |
||||
if data == nil && withUserPresence && shouldWriteWithUserPresenceIfNotFound && keychain.isDataNotFoundForLastAccess { |
||||
switch getPrivateKeyFromNonHdWallet(forAccount: account, prompt: prompt, withUserPresence: false, shouldWriteWithUserPresenceIfNotFound: false) { |
||||
case .seed, .seedPhrase: |
||||
//Not possible |
||||
break |
||||
case .key(let keyWithoutUserPresence): |
||||
savePrivateKeyForNonHdWallet(keyWithoutUserPresence, forAccount: account, withUserPresence: true) |
||||
case .userCancelled, .notFound, .otherFailure: |
||||
break |
||||
} |
||||
return getPrivateKeyFromNonHdWallet(forAccount: account, prompt: prompt, withUserPresence: true, shouldWriteWithUserPresenceIfNotFound: false) |
||||
} else { |
||||
if let data = data { |
||||
return .key(data) |
||||
} else { |
||||
if keychain.hasUserCancelledLastAccess { |
||||
return .userCancelled |
||||
} else if keychain.isDataNotFoundForLastAccess { |
||||
return .notFound |
||||
} else { |
||||
return .otherFailure |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func getSeedPhraseForHdWallet(forAccount account: EthereumAccount, prompt: String, withUserPresence: Bool) -> WalletSeedOrKey { |
||||
let seedOrKey = getSeedForHdWallet(forAccount: account, prompt: prompt, withUserPresence: withUserPresence) |
||||
switch seedOrKey { |
||||
case .seed(let seed): |
||||
return .seedPhrase(HDWallet(seed: seed, passphrase: emptyPassphrase).mnemonic) |
||||
case .seedPhrase, .key: |
||||
//Not possible |
||||
return seedOrKey |
||||
case .userCancelled, .notFound, .otherFailure: |
||||
return seedOrKey |
||||
} |
||||
} |
||||
|
||||
private func getSeedForHdWallet(forAccount account: EthereumAccount, prompt: String, withUserPresence: Bool, shouldWriteWithUserPresenceIfNotFound: Bool = true) -> WalletSeedOrKey { |
||||
let prefix: String |
||||
if withUserPresence { |
||||
prefix = Keys.ethereumSeedUserPresenceRequiredPrefix |
||||
} else { |
||||
prefix = Keys.ethereumSeedUserPresenceNotRequiredPrefix |
||||
} |
||||
let context = createContext() |
||||
let data = keychain.getData("\(prefix)\(account.address.eip55String)", prompt: prompt, withContext: context) |
||||
.flatMap { decryptHdWalletSeed(fromCipherTextData: $0, forAccount: account, withUserPresence: withUserPresence, withContext: context) } |
||||
.flatMap { String(data: $0, encoding: .utf8) } |
||||
//We copy the record that doesn't require user-presence make a new one which requires user-presence and read from that. We don't want to read the one without user-presence unless absolutely necessary (e.g user has disabled passcode) |
||||
if data == nil && withUserPresence && shouldWriteWithUserPresenceIfNotFound && keychain.isDataNotFoundForLastAccess { |
||||
switch getSeedForHdWallet(forAccount: account, prompt: prompt, withUserPresence: false, shouldWriteWithUserPresenceIfNotFound: false) { |
||||
case .seed(let seedWithoutUserPresence): |
||||
saveSeedForHdWallet(seedWithoutUserPresence, forAccount: account, withUserPresence: true) |
||||
case .key, .seedPhrase: |
||||
//Not possible |
||||
break |
||||
case .userCancelled, .notFound, .otherFailure: |
||||
break |
||||
} |
||||
return getSeedForHdWallet(forAccount: account, prompt: prompt, withUserPresence: true, shouldWriteWithUserPresenceIfNotFound: false) |
||||
} else { |
||||
if let data = data { |
||||
return .seed(data) |
||||
} else { |
||||
if keychain.hasUserCancelledLastAccess { |
||||
return .userCancelled |
||||
} else if keychain.isDataNotFoundForLastAccess { |
||||
return .notFound |
||||
} else { |
||||
return .otherFailure |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func savePrivateKeyForNonHdWallet(_ privateKey: Data, forAccount account: EthereumAccount, withUserPresence: Bool) -> Bool { |
||||
let context = createContext() |
||||
guard let cipherTextData = encryptPrivateKey(privateKey, forAccount: account, withUserPresence: withUserPresence, withContext: context) else { return false } |
||||
let access: KeychainSwiftAccessOptions |
||||
let prefix: String |
||||
if withUserPresence { |
||||
access = defaultKeychainAccessUserPresenceRequired |
||||
prefix = Keys.ethereumRawPrivateKeyUserPresenceRequiredPrefix |
||||
} else { |
||||
access = defaultKeychainAccessUserPresenceNotRequired |
||||
prefix = Keys.ethereumRawPrivateKeyUserPresenceNotRequiredPrefix |
||||
} |
||||
return keychain.set(cipherTextData, forKey: "\(prefix)\(account.address.eip55String)", withAccess: access) |
||||
} |
||||
|
||||
private func saveSeedForHdWallet(_ seed: String, forAccount account: EthereumAccount, withUserPresence: Bool) -> Bool { |
||||
let context = createContext() |
||||
guard let cipherTextData = seed.data(using: .utf8).flatMap({ self.encryptHdWalletSeed($0, forAccount: account, withUserPresence: withUserPresence, withContext: context) }) else { return false } |
||||
let access: KeychainSwiftAccessOptions |
||||
let prefix: String |
||||
if withUserPresence { |
||||
access = defaultKeychainAccessUserPresenceRequired |
||||
prefix = Keys.ethereumSeedUserPresenceRequiredPrefix |
||||
} else { |
||||
access = defaultKeychainAccessUserPresenceNotRequired |
||||
prefix = Keys.ethereumSeedUserPresenceNotRequiredPrefix |
||||
} |
||||
return keychain.set(cipherTextData, forKey: "\(prefix)\(account.address.eip55String)", withAccess: access) |
||||
} |
||||
|
||||
private func decryptHdWalletSeed(fromCipherTextData cipherTextData: Data, forAccount account: EthereumAccount, withUserPresence: Bool, withContext context: LAContext) -> Data? { |
||||
let secureEnclave = SecureEnclave(userPresenceRequired: withUserPresence) |
||||
return try? secureEnclave.decrypt(cipherText: cipherTextData, withPrivateKeyFromLabel: encryptionKeyForSeedLabel(fromAccount: account, withUserPresence: withUserPresence), withContext: context) |
||||
} |
||||
|
||||
private func decryptPrivateKey(fromCipherTextData cipherTextData: Data, forAccount account: EthereumAccount, withUserPresence: Bool, withContext context: LAContext) -> Data? { |
||||
let secureEnclave = SecureEnclave(userPresenceRequired: withUserPresence) |
||||
return try? secureEnclave.decrypt(cipherText: cipherTextData, withPrivateKeyFromLabel: encryptionKeyForPrivateKeyLabel(fromAccount: account, withUserPresence: withUserPresence), withContext: context) |
||||
} |
||||
|
||||
private func encryptHdWalletSeed(_ seed: Data, forAccount account: EthereumAccount, withUserPresence: Bool, withContext context: LAContext) -> Data? { |
||||
let secureEnclave = SecureEnclave(userPresenceRequired: withUserPresence) |
||||
return try? secureEnclave.encrypt(plainTextData: seed, withPublicKeyFromLabel: encryptionKeyForSeedLabel(fromAccount: account, withUserPresence: withUserPresence), withContext: context) |
||||
} |
||||
|
||||
private func encryptPrivateKey(_ key: Data, forAccount account: EthereumAccount, withUserPresence: Bool, withContext context: LAContext) -> Data? { |
||||
let secureEnclave = SecureEnclave(userPresenceRequired: withUserPresence) |
||||
return try? secureEnclave.encrypt(plainTextData: key, withPublicKeyFromLabel: encryptionKeyForPrivateKeyLabel(fromAccount: account, withUserPresence: withUserPresence), withContext: context) |
||||
} |
||||
|
||||
private func encryptionKeyForSeedLabel(fromAccount account: EthereumAccount, withUserPresence: Bool) -> String { |
||||
let prefix: String |
||||
if withUserPresence { |
||||
prefix = Keys.encryptionKeyForSeedUserPresenceRequiredPrefix |
||||
} else { |
||||
prefix = Keys.encryptionKeyForSeedUserPresenceNotRequiredPrefix |
||||
} |
||||
return "\(prefix)\(account.address.eip55String)" |
||||
} |
||||
|
||||
private func encryptionKeyForPrivateKeyLabel(fromAccount account: EthereumAccount, withUserPresence: Bool) -> String { |
||||
let prefix: String |
||||
if withUserPresence { |
||||
prefix = Keys.encryptionKeyForPrivateKeyUserPresenceRequiredPrefix |
||||
} else { |
||||
prefix = Keys.encryptionKeyForPrivateKeyUserPresenceNotRequiredPrefix |
||||
} |
||||
return "\(prefix)\(account.address.eip55String)" |
||||
} |
||||
|
||||
func elevateSecurity(forAccount account: EthereumAccount) -> Bool { |
||||
guard !isProtectedByUserPresence(account: account) else { return true } |
||||
guard isUserPresenceCheckPossible else { return false } |
||||
let prompt: String |
||||
var isSuccessful: Bool |
||||
if isHdWallet(account: account) { |
||||
prompt = R.string.localizable.keystoreAccessKeyHdLock() |
||||
let seed = getSeedForHdWallet(forAccount: account, prompt: prompt, withUserPresence: false) |
||||
switch seed { |
||||
case .seed(let seed): |
||||
isSuccessful = saveSeedForHdWallet(seed, forAccount: account, withUserPresence: true) |
||||
if isSuccessful { |
||||
//Read it back, forcing iOS to check for user-presence |
||||
switch getSeedForHdWallet(forAccount: account, prompt: prompt, withUserPresence: true) { |
||||
case .seed: |
||||
isSuccessful = true |
||||
case .key, .seedPhrase: |
||||
//Not possible |
||||
isSuccessful = false |
||||
case .userCancelled, .notFound, .otherFailure: |
||||
isSuccessful = false |
||||
} |
||||
} |
||||
case .key, .seedPhrase: |
||||
//Not possible |
||||
return false |
||||
case .userCancelled: |
||||
return false |
||||
case .notFound, .otherFailure: |
||||
return false |
||||
} |
||||
} else { |
||||
prompt = R.string.localizable.keystoreAccessKeyNonHdLock() |
||||
let keyStoredAsRawPrivateKey = getPrivateKeyFromNonHdWallet(forAccount: account, prompt: prompt, withUserPresence: false) |
||||
switch keyStoredAsRawPrivateKey { |
||||
case .seed, .seedPhrase: |
||||
//Not possible |
||||
return false |
||||
case .key(let keyStoredAsRawPrivateKey): |
||||
isSuccessful = savePrivateKeyForNonHdWallet(keyStoredAsRawPrivateKey, forAccount: account, withUserPresence: true) |
||||
if isSuccessful { |
||||
//Read it back, forcing iOS to check for user-presence |
||||
switch getPrivateKeyFromNonHdWallet(forAccount: account, prompt: prompt, withUserPresence: true) { |
||||
case .seed, .seedPhrase: |
||||
//Not possible |
||||
isSuccessful = false |
||||
case .key: |
||||
isSuccessful = true |
||||
case .userCancelled, .notFound, .otherFailure: |
||||
isSuccessful = false |
||||
} |
||||
} |
||||
case .userCancelled: |
||||
return false |
||||
case .notFound, .otherFailure: |
||||
return false |
||||
} |
||||
} |
||||
if isSuccessful { |
||||
addToListOfEthereumAddressesProtectedByUserPresence(account.address) |
||||
let secureEnclave = SecureEnclave() |
||||
if isHdWallet(account: account) { |
||||
secureEnclave.deletePrivateKeys(withName: encryptionKeyForSeedLabel(fromAccount: account, withUserPresence: false)) |
||||
keychain.delete("\(Keys.ethereumSeedUserPresenceNotRequiredPrefix)\(account.address.eip55String)") |
||||
} else { |
||||
secureEnclave.deletePrivateKeys(withName: encryptionKeyForPrivateKeyLabel(fromAccount: account, withUserPresence: false)) |
||||
keychain.delete("\(Keys.ethereumRawPrivateKeyUserPresenceNotRequiredPrefix)\(account.address.eip55String)") |
||||
} |
||||
} |
||||
return isSuccessful |
||||
} |
||||
|
||||
private func createContext() -> LAContext { |
||||
return .init() |
||||
} |
||||
} |
||||
// swiftlint:enable type_body_length |
||||
|
||||
extension KeychainSwift { |
||||
var hasUserCancelledLastAccess: Bool { |
||||
return lastResultCode == errSecUserCanceled |
||||
} |
||||
|
||||
var isDataNotFoundForLastAccess: Bool { |
||||
return lastResultCode == errSecItemNotFound |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import TrustKeystore |
||||
|
||||
struct EthereumAccount: Hashable { |
||||
var address: AlphaWallet.Address |
||||
|
||||
init(address: AlphaWallet.Address) { |
||||
self.address = address |
||||
} |
||||
|
||||
public var hashValue: Int { |
||||
return address.hashValue |
||||
} |
||||
|
||||
public static func == (lhs: EthereumAccount, rhs: EthereumAccount) -> Bool { |
||||
return lhs.address == rhs.address |
||||
} |
||||
} |
||||
|
||||
extension EthereumAccount { |
||||
init(account: Account) { |
||||
self.init(address: .init(address: account.address)) |
||||
} |
||||
} |
@ -0,0 +1,14 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import TrustKeystore |
||||
|
||||
struct EthereumSigner { |
||||
public func sign(hash: Data, withPrivateKey key: Data) throws -> Data { |
||||
return try Secp256k1.shared.sign(hash: hash, privateKey: key) |
||||
} |
||||
|
||||
public func signHashes(_ hashes: [Data], withPrivateKey key: Data) throws -> [Data] { |
||||
return try hashes.map { try sign(hash: $0, withPrivateKey: key) } |
||||
} |
||||
} |
@ -0,0 +1,47 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import Foundation |
||||
import Result |
||||
|
||||
enum KeystoreExportReason { |
||||
case backup |
||||
case prepareForVerification |
||||
|
||||
var prompt: String { |
||||
switch self { |
||||
case .backup: |
||||
return R.string.localizable.keystoreAccessKeyHdBackup() |
||||
case .prepareForVerification: |
||||
return R.string.localizable.keystoreAccessKeyHdPrepareToVerify() |
||||
} |
||||
} |
||||
} |
||||
|
||||
protocol Keystore { |
||||
static var current: Wallet? { get } |
||||
|
||||
var hasWallets: Bool { get } |
||||
var isUserPresenceCheckPossible: Bool { get } |
||||
var wallets: [Wallet] { get } |
||||
var recentlyUsedWallet: Wallet? { get set } |
||||
|
||||
@available(iOS 10.0, *) |
||||
func createAccount(completion: @escaping (Result<EthereumAccount, KeystoreError>) -> Void) |
||||
func importWallet(type: ImportType, completion: @escaping (Result<Wallet, KeystoreError>) -> Void) |
||||
func createAccount() -> Result<EthereumAccount, KeystoreError> |
||||
func elevateSecurity(forAccount account: EthereumAccount) -> Bool |
||||
func exportRawPrivateKeyForNonHdWalletForBackup(forAccount: EthereumAccount, newPassword: String, completion: @escaping (Result<String, KeystoreError>) -> Void) |
||||
func exportSeedPhraseOfHdWallet(forAccount account: EthereumAccount, reason: KeystoreExportReason, completion: @escaping (Result<String, KeystoreError>) -> Void) |
||||
func verifySeedPhraseOfHdWallet(_ seedPhrase: String, forAccount account: EthereumAccount, completion: @escaping (Result<Bool, KeystoreError>) -> Void) |
||||
func delete(wallet: Wallet, completion: @escaping (Result<Void, KeystoreError>) -> Void) |
||||
func isHdWallet(account: EthereumAccount) -> Bool |
||||
func isHdWallet(wallet: Wallet) -> Bool |
||||
func isKeystore(wallet: Wallet) -> Bool |
||||
func isWatched(wallet: Wallet) -> Bool |
||||
func isProtectedByUserPresence(account: EthereumAccount) -> Bool |
||||
func signPersonalMessage(_ data: Data, for account: EthereumAccount) -> Result<Data, KeystoreError> |
||||
func signTypedMessage(_ datas: [EthTypedData], for account: EthereumAccount) -> Result<Data, KeystoreError> |
||||
func signMessage(_ data: Data, for account: EthereumAccount) -> Result<Data, KeystoreError> |
||||
func signHash(_ data: Data, for account: EthereumAccount) -> Result<Data, KeystoreError> |
||||
func signTransaction(_ signTransaction: UnsignedTransaction) -> Result<Data, KeystoreError> |
||||
} |
@ -0,0 +1,120 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import BigInt |
||||
import Foundation |
||||
import Result |
||||
import KeychainSwift |
||||
import CryptoSwift |
||||
import TrustKeystore |
||||
|
||||
enum FileBasedKeystoreError: LocalizedError { |
||||
case protectionDisabled |
||||
} |
||||
|
||||
class LegacyFileBasedKeystore { |
||||
private let keychain: KeychainSwift |
||||
private let datadir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] |
||||
private let keyStore: KeyStore |
||||
private let defaultKeychainAccess: KeychainSwiftAccessOptions = .accessibleWhenUnlockedThisDeviceOnly(userPresenceRequired: false) |
||||
private let userDefaults: UserDefaults |
||||
|
||||
let keystoreDirectory: URL |
||||
|
||||
public init( |
||||
keychain: KeychainSwift = KeychainSwift(keyPrefix: Constants.keychainKeyPrefix), |
||||
keyStoreSubfolder: String = "/keystore", |
||||
userDefaults: UserDefaults = UserDefaults.standard |
||||
) throws { |
||||
if !UIApplication.shared.isProtectedDataAvailable { |
||||
throw FileBasedKeystoreError.protectionDisabled |
||||
} |
||||
self.keystoreDirectory = URL(fileURLWithPath: datadir + keyStoreSubfolder) |
||||
self.keychain = keychain |
||||
self.keychain.synchronizable = false |
||||
self.keyStore = try KeyStore(keydir: keystoreDirectory) |
||||
self.userDefaults = userDefaults |
||||
} |
||||
|
||||
func getPrivateKeyFromKeystoreFile(json: String, password: String) -> Result<Data, KeystoreError> { |
||||
let newPassword = PasswordGenerator.generateRandom() |
||||
guard let data = json.data(using: .utf8) else { return .failure(.failedToDecryptKey) } |
||||
guard let key = try? JSONDecoder().decode(KeystoreKey.self, from: data) else { return .failure(.failedToImportPrivateKey) } |
||||
guard let privateKey = try? key.decrypt(password: password) else { return .failure(.failedToDecryptKey) } |
||||
return .success(privateKey) |
||||
} |
||||
|
||||
func export(privateKey: Data, newPassword: String) -> Result<String, KeystoreError> { |
||||
switch convertPrivateKeyToKeystoreFile(privateKey: privateKey, passphrase: newPassword) { |
||||
case .success(let dict): |
||||
if let jsonString = dict.jsonString { |
||||
return .success(jsonString) |
||||
} else { |
||||
return .failure(.failedToExportPrivateKey) |
||||
} |
||||
case .failure(let error): |
||||
return .failure(.failedToExportPrivateKey) |
||||
} |
||||
} |
||||
|
||||
private func exportPrivateKey(account: EthereumAccount) -> Result<Data, KeystoreError> { |
||||
guard let password = getPassword(for: account) else { return .failure(KeystoreError.accountNotFound) } |
||||
guard let account = getAccount(forAddress: account.address) else { return .failure(.accountNotFound) } |
||||
do { |
||||
let privateKey = try keyStore.exportPrivateKey(account: account, password: password) |
||||
return .success(privateKey) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToExportPrivateKey) |
||||
} |
||||
} |
||||
|
||||
@discardableResult func delete(wallet: Wallet) -> Result<Void, KeystoreError> { |
||||
switch wallet.type { |
||||
case .real(let ethereumAccount): |
||||
guard let account = getAccount(forAddress: ethereumAccount.address) else { return .failure(.accountNotFound) } |
||||
guard let password = getPassword(for: ethereumAccount) else { return .failure(.failedToDeleteAccount) } |
||||
|
||||
do { |
||||
try keyStore.delete(account: account, password: password) |
||||
return .success(()) |
||||
} catch { |
||||
return .failure(.failedToDeleteAccount) |
||||
} |
||||
case .watch(let address): |
||||
return .success(()) |
||||
} |
||||
} |
||||
|
||||
func getPassword(for account: EthereumAccount) -> String? { |
||||
//This has to be lowercased due to legacy reasons — it had been written to as lowercased() earlier |
||||
return keychain.get(account.address.eip55String.lowercased()) |
||||
} |
||||
|
||||
func getAccount(forAddress address: AlphaWallet.Address) -> Account? { |
||||
return keyStore.account(for: .init(address: address)) |
||||
} |
||||
|
||||
func convertPrivateKeyToKeystoreFile(privateKey: Data, passphrase: String) -> Result<[String: Any], KeystoreError> { |
||||
do { |
||||
let key = try KeystoreKey(password: passphrase, key: privateKey) |
||||
let data = try JSONEncoder().encode(key) |
||||
let dict = try JSONSerialization.jsonObject(with: data, options: []) as! [String: Any] |
||||
return .success(dict) |
||||
} catch { |
||||
return .failure(KeystoreError.failedToImportPrivateKey) |
||||
} |
||||
} |
||||
|
||||
func migrateKeystoreFilesToRawPrivateKeysInKeychain() { |
||||
guard let etherKeystore = try? EtherKeystore() else { return } |
||||
guard !etherKeystore.hasMigratedFromKeystoreFiles else { return } |
||||
|
||||
for each in keyStore.accounts { |
||||
switch exportPrivateKey(account: .init(account: each)) { |
||||
case .success(let privateKey): |
||||
etherKeystore.importWallet(type: .privateKey(privateKey: privateKey), completion: { _ in }) |
||||
case .failure: |
||||
break |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,180 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import LocalAuthentication |
||||
import Security |
||||
|
||||
class SecureEnclave { |
||||
enum Error: LocalizedError { |
||||
case keyAlreadyExists(name: String) |
||||
case cannotAccessPrivateKey(osStatus: OSStatus) |
||||
case cannotAccessPublicKey |
||||
case encryptionNotSupported(algorithm: SecKeyAlgorithm) |
||||
case decryptionNotSupported(algorithm: SecKeyAlgorithm) |
||||
case cannotEncrypt |
||||
case cannotDecrypt |
||||
case unexpected(description: String) |
||||
|
||||
var errorDescription: String { |
||||
switch self { |
||||
case .keyAlreadyExists(let name): |
||||
return "Encryption key already exist for: \(name)" |
||||
case .cannotAccessPrivateKey(let osStatus): |
||||
return "Cannot access private key because: \(osStatus)" |
||||
case .cannotAccessPublicKey: |
||||
return "Cannot access public key" |
||||
case .encryptionNotSupported(let algorithm): |
||||
return "Encryption not supported: \(algorithm)" |
||||
case .decryptionNotSupported(let algorithm): |
||||
return "Decryption not supported: \(algorithm)" |
||||
case .cannotEncrypt: |
||||
return "Cannot encrypt" |
||||
case .cannotDecrypt: |
||||
return "Cannot decrypt" |
||||
case .unexpected(let description): |
||||
return description |
||||
} |
||||
} |
||||
} |
||||
|
||||
private let requiresBiometry = true |
||||
private let numberOfBitsInKey = 256 |
||||
private let algorithm = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM |
||||
private let userPresenceRequired: Bool |
||||
|
||||
private var isSimulator: Bool { |
||||
return TARGET_OS_SIMULATOR != 0 |
||||
} |
||||
|
||||
init(userPresenceRequired: Bool = false) { |
||||
self.userPresenceRequired = userPresenceRequired |
||||
} |
||||
|
||||
private func getPrivateKey(withName name: String, withContext context: LAContext) throws -> SecKey { |
||||
let params: [String: Any] = [ |
||||
kSecClass as String: kSecClassKey, |
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
||||
kSecAttrApplicationTag as String: tagData(fromName: name), |
||||
kSecReturnRef as String: true, |
||||
kSecUseAuthenticationContext as String: context, |
||||
] |
||||
|
||||
var raw: CFTypeRef? |
||||
let status = SecItemCopyMatching(params as CFDictionary, &raw) |
||||
guard status == errSecSuccess, let result = raw else { |
||||
throw Error.cannotAccessPrivateKey(osStatus: status) |
||||
} |
||||
return result as! SecKey |
||||
} |
||||
|
||||
private func getPrivateKeyCount(withName name: String) -> Int { |
||||
let params: [String: Any] = [ |
||||
kSecClass as String: kSecClassKey, |
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
||||
kSecAttrApplicationTag as String: tagData(fromName: name), |
||||
kSecReturnRef as String: true, |
||||
kSecMatchLimit as String: kSecMatchLimitAll |
||||
] |
||||
|
||||
var raw: CFTypeRef? |
||||
let status = SecItemCopyMatching(params as CFDictionary, &raw) |
||||
if status == errSecSuccess, let all = raw as? [SecKey] { |
||||
return all.count |
||||
} else { |
||||
return 0 |
||||
} |
||||
} |
||||
|
||||
private func encrypt(plainTextData: Data, withPublicKey publicKey: SecKey) throws -> Data { |
||||
guard SecKeyIsAlgorithmSupported(publicKey, .encrypt, algorithm) else { |
||||
throw Error.encryptionNotSupported(algorithm: algorithm) |
||||
} |
||||
var error: Unmanaged<CFError>? |
||||
guard let cipherTextData = SecKeyCreateEncryptedData(publicKey, algorithm, plainTextData as CFData, &error) as? Data else { |
||||
throw Error.cannotEncrypt |
||||
} |
||||
return cipherTextData |
||||
} |
||||
|
||||
private func decrypt(cipherText: Data, privateKey: SecKey) throws -> Data { |
||||
guard SecKeyIsAlgorithmSupported(privateKey, .decrypt, algorithm) else { |
||||
throw Error.decryptionNotSupported(algorithm: algorithm) |
||||
} |
||||
|
||||
var error: Unmanaged<CFError>? |
||||
guard let plainTextData = SecKeyCreateDecryptedData(privateKey, algorithm, cipherText as CFData, &error) as Data? else { |
||||
throw Error.cannotDecrypt |
||||
} |
||||
return plainTextData |
||||
} |
||||
|
||||
private func tagData(fromName name: String) -> Data { |
||||
return name.data(using: .utf8)! |
||||
} |
||||
|
||||
private func createPrivateKey(withName name: String) throws -> SecKey { |
||||
let count = getPrivateKeyCount(withName: name) |
||||
// swiftlint:disable empty_count |
||||
guard count == 0 else { throw Error.keyAlreadyExists(name: name) } |
||||
// swiftlint:enable empty_count |
||||
|
||||
let flags: SecAccessControlCreateFlags |
||||
if requiresBiometry && userPresenceRequired { |
||||
flags = [.privateKeyUsage, .userPresence] |
||||
} else { |
||||
flags = .privateKeyUsage |
||||
} |
||||
guard let access = SecAccessControlCreateWithFlags(kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, nil) else { throw Error.unexpected(description: "Unable to create flags to create private key") } |
||||
var attributes: [String: Any] = [ |
||||
kSecAttrKeyType as String : kSecAttrKeyTypeECSECPrimeRandom, |
||||
kSecAttrKeySizeInBits as String: numberOfBitsInKey, |
||||
kSecPrivateKeyAttrs as String : [ |
||||
kSecAttrIsPermanent as String: true, |
||||
kSecAttrApplicationTag as String: tagData(fromName: name), |
||||
kSecAttrAccessControl as String: access |
||||
] |
||||
] |
||||
if isSimulator { |
||||
//do nothing |
||||
} else { |
||||
attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave |
||||
} |
||||
|
||||
var error: Unmanaged<CFError>? |
||||
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { |
||||
throw error!.takeRetainedValue() as Swift.Error |
||||
} |
||||
|
||||
return privateKey |
||||
} |
||||
|
||||
//MARK: Public interface |
||||
|
||||
func encrypt(plainTextData: Data, withPublicKeyFromLabel name: String, withContext context: LAContext) throws -> Data { |
||||
let privateKey: SecKey |
||||
do { |
||||
privateKey = try getPrivateKey(withName: name, withContext: context) |
||||
} catch { |
||||
privateKey = try createPrivateKey(withName: name) |
||||
} |
||||
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else { throw Error.cannotAccessPublicKey } |
||||
|
||||
|
||||
return try encrypt(plainTextData: plainTextData, withPublicKey: publicKey) |
||||
} |
||||
|
||||
func decrypt(cipherText: Data, withPrivateKeyFromLabel name: String, withContext context: LAContext) throws -> Data { |
||||
let privateKey = try getPrivateKey(withName: name, withContext: context) |
||||
return try decrypt(cipherText: cipherText, privateKey: privateKey) |
||||
} |
||||
|
||||
func deletePrivateKeys(withName name: String) { |
||||
let params: [String: Any] = [ |
||||
kSecClass as String: kSecClassKey, |
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
||||
kSecAttrApplicationTag as String: tagData(fromName: name) |
||||
] |
||||
let status = SecItemDelete(params as CFDictionary) |
||||
} |
||||
} |
File diff suppressed because one or more lines are too long
@ -0,0 +1,23 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import Eureka |
||||
import TrustWalletCore |
||||
|
||||
struct MnemonicRule<T: Equatable>: RuleType { |
||||
public init(msg: String = "") { |
||||
let msg = msg.isEmpty ? R.string.localizable.importWalletImportInvalidMnemonic() : msg |
||||
self.validationError = ValidationError(msg: msg) |
||||
} |
||||
|
||||
public var id: String? |
||||
public var validationError: ValidationError |
||||
|
||||
public func isValid(value: T?) -> ValidationError? { |
||||
if let str = value as? String { |
||||
let words = str.trimmed.split(separator: " ") |
||||
return (words.count != HDWallet.mnemonicWordCount) ? validationError : nil |
||||
} |
||||
return value != nil ? nil : validationError |
||||
} |
||||
} |
@ -0,0 +1,60 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
class SuccessOverlayView: UIView { |
||||
static func show() { |
||||
let view = SuccessOverlayView(frame: UIScreen.main.bounds) |
||||
view.show() |
||||
} |
||||
|
||||
private let imageView = UIImageView(image: R.image.successOverlay()!) |
||||
|
||||
override init(frame: CGRect) { |
||||
super.init(frame: frame) |
||||
|
||||
let blurEffect = UIBlurEffect(style: .extraLight) |
||||
let blurView = UIVisualEffectView(effect: blurEffect) |
||||
blurView.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(blurView) |
||||
blurView.alpha = 0.3 |
||||
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(imageView) |
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(hide)) |
||||
addGestureRecognizer(tapGestureRecognizer) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
blurView.leadingAnchor.constraint(equalTo: leadingAnchor), |
||||
blurView.trailingAnchor.constraint(equalTo: trailingAnchor), |
||||
blurView.topAnchor.constraint(equalTo: topAnchor), |
||||
blurView.bottomAnchor.constraint(equalTo: bottomAnchor), |
||||
|
||||
imageView.centerXAnchor.constraint(equalTo: centerXAnchor), |
||||
imageView.centerYAnchor.constraint(equalTo: centerYAnchor), |
||||
]) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
@objc private func hide() { |
||||
removeFromSuperview() |
||||
} |
||||
|
||||
func show() { |
||||
imageView.transform = CGAffineTransform(scaleX: 0.6, y: 0.6) |
||||
UIApplication.shared.keyWindow?.addSubview(self) |
||||
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 7, options: .curveEaseInOut, animations: { |
||||
self.imageView.transform = .identity |
||||
}) |
||||
|
||||
//TODO sound too |
||||
let feedbackGenerator = UINotificationFeedbackGenerator() |
||||
feedbackGenerator.prepare() |
||||
feedbackGenerator.notificationOccurred(.success) |
||||
} |
||||
} |
@ -0,0 +1,149 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
class WhereIsWalletAddressFoundOverlayView: UIView { |
||||
static func show() { |
||||
let view = WhereIsWalletAddressFoundOverlayView(frame: UIScreen.main.bounds) |
||||
view.show() |
||||
} |
||||
|
||||
private let dialog = Dialog() |
||||
|
||||
override init(frame: CGRect) { |
||||
super.init(frame: frame) |
||||
|
||||
backgroundColor = .init(red: 0, green: 0, blue: 0, alpha: 0.3) |
||||
|
||||
let blurEffect = UIBlurEffect(style: .regular) |
||||
let blurView = UIVisualEffectView(effect: blurEffect) |
||||
blurView.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(blurView) |
||||
blurView.alpha = 0.3 |
||||
|
||||
clipBottomRight() |
||||
|
||||
dialog.delegate = self |
||||
dialog.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(dialog) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
blurView.leadingAnchor.constraint(equalTo: leadingAnchor), |
||||
blurView.trailingAnchor.constraint(equalTo: trailingAnchor), |
||||
blurView.topAnchor.constraint(equalTo: topAnchor), |
||||
blurView.bottomAnchor.constraint(equalTo: bottomAnchor), |
||||
|
||||
dialog.rightAnchor.constraint(equalTo: rightAnchor, constant: -20), |
||||
dialog.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -120), |
||||
]) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
private func clipBottomRight() { |
||||
//TODO support clipping for iPad too |
||||
if UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiom.phone { |
||||
let clipDimension = CGFloat(180) |
||||
let clipPath = UIBezierPath(ovalIn: CGRect(x: UIScreen.main.bounds.size.width - clipDimension / 2 - 20, y: UIScreen.main.bounds.size.height - clipDimension / 2 - 20, width: clipDimension, height: clipDimension)) |
||||
let maskPath = UIBezierPath(rect: UIScreen.main.bounds) |
||||
maskPath.append(clipPath.reversing()) |
||||
let mask = CAShapeLayer() |
||||
mask.backgroundColor = UIColor.red.cgColor |
||||
mask.path = maskPath.cgPath |
||||
layer.mask = mask |
||||
} |
||||
} |
||||
|
||||
@objc private func hide() { |
||||
removeFromSuperview() |
||||
} |
||||
|
||||
func show() { |
||||
dialog.configure() |
||||
dialog.transform = CGAffineTransform(scaleX: 0.6, y: 0.6) |
||||
UIApplication.shared.keyWindow?.addSubview(self) |
||||
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 7, options: .curveEaseInOut, animations: { |
||||
self.dialog.transform = .identity |
||||
}) |
||||
|
||||
//TODO sound too |
||||
let feedbackGenerator = UINotificationFeedbackGenerator() |
||||
feedbackGenerator.prepare() |
||||
feedbackGenerator.notificationOccurred(.success) |
||||
} |
||||
} |
||||
|
||||
extension WhereIsWalletAddressFoundOverlayView: DialogDelegate { |
||||
fileprivate func tappedContinue(inDialog dialog: Dialog) { |
||||
hide() |
||||
} |
||||
} |
||||
|
||||
fileprivate protocol DialogDelegate: class { |
||||
func tappedContinue(inDialog dialog: Dialog) |
||||
} |
||||
|
||||
fileprivate class Dialog: UIView { |
||||
private let titleLabel = UILabel() |
||||
private let descriptionLabel = UILabel() |
||||
private let buttonsBar = ButtonsBar(numberOfButtons: 1) |
||||
|
||||
weak var delegate: DialogDelegate? |
||||
|
||||
override init(frame: CGRect) { |
||||
super.init(frame: frame) |
||||
|
||||
let stackView = [ |
||||
titleLabel, |
||||
UIView.spacer(height: 12), |
||||
descriptionLabel, |
||||
UIView.spacer(height: 30), |
||||
buttonsBar |
||||
].asStackView(axis: .vertical) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
addSubview(stackView) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
buttonsBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
widthAnchor.constraint(equalToConstant: 300), |
||||
heightAnchor.constraint(equalToConstant: 250), |
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: topAnchor, constant: 30), |
||||
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -30), |
||||
]) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
func configure() { |
||||
backgroundColor = Colors.appWhite |
||||
|
||||
titleLabel.font = Fonts.regular(size: 24) |
||||
titleLabel.textColor = .init(red: 33, green: 33, blue: 33) |
||||
titleLabel.textAlignment = .center |
||||
titleLabel.text = R.string.localizable.onboardingNewWalletFindAddressTitle() |
||||
|
||||
descriptionLabel.numberOfLines = 0 |
||||
descriptionLabel.font = Fonts.regular(size: 18) |
||||
descriptionLabel.textColor = .init(red: 102, green: 102, blue: 102) |
||||
descriptionLabel.textAlignment = .center |
||||
descriptionLabel.text = R.string.localizable.onboardingNewWalletFindAddressDescription() |
||||
|
||||
buttonsBar.configure() |
||||
let continueButton = buttonsBar.buttons[0] |
||||
continueButton.setTitle("Continue".localizedUppercase, for: .normal) |
||||
continueButton.addTarget(self, action: #selector(hide), for: .touchUpInside) |
||||
} |
||||
|
||||
@objc private func hide() { |
||||
delegate?.tappedContinue(inDialog: self) |
||||
} |
||||
} |
@ -0,0 +1,54 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
protocol BackupSeedPhraseCoordinatorDelegate: class { |
||||
func didTapTestSeedPhrase(forAccount account: EthereumAccount, inCoordinator coordinator: BackupSeedPhraseCoordinator) |
||||
func didClose(forAccount account: EthereumAccount, inCoordinator coordinator: BackupSeedPhraseCoordinator) |
||||
} |
||||
|
||||
class BackupSeedPhraseCoordinator: Coordinator { |
||||
private lazy var rootViewController: ShowSeedPhraseViewController = { |
||||
let controller = ShowSeedPhraseViewController(keystore: keystore, account: account) |
||||
controller.configure() |
||||
controller.delegate = self |
||||
return controller |
||||
}() |
||||
private let account: EthereumAccount |
||||
private let keystore: Keystore |
||||
|
||||
let navigationController: UINavigationController |
||||
var coordinators: [Coordinator] = [] |
||||
weak var delegate: BackupSeedPhraseCoordinatorDelegate? |
||||
|
||||
init(navigationController: UINavigationController = UINavigationController(), keystore: Keystore, account: EthereumAccount) { |
||||
self.navigationController = navigationController |
||||
self.keystore = keystore |
||||
self.account = account |
||||
} |
||||
|
||||
func start() { |
||||
navigationController.pushViewController(rootViewController, animated: true) |
||||
} |
||||
|
||||
func end() { |
||||
rootViewController.markDone() |
||||
} |
||||
|
||||
func endUserInterface(animated: Bool) { |
||||
navigationController.viewControllers.firstIndex(of: rootViewController) |
||||
.flatMap { navigationController.viewControllers[$0 - 1] } |
||||
.flatMap { navigationController.popToViewController($0, animated: animated) } |
||||
} |
||||
} |
||||
|
||||
extension BackupSeedPhraseCoordinator: ShowSeedPhraseViewControllerDelegate { |
||||
func didTapTestSeedPhrase(for account: EthereumAccount, inViewController viewController: ShowSeedPhraseViewController) { |
||||
delegate?.didTapTestSeedPhrase(forAccount: account, inCoordinator: self) |
||||
} |
||||
|
||||
func didClose(for account: EthereumAccount, inViewController viewController: ShowSeedPhraseViewController) { |
||||
delegate?.didClose(forAccount: account, inCoordinator: self) |
||||
} |
||||
} |
@ -0,0 +1,65 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
protocol ElevateWalletSecurityCoordinatorDelegate: class { |
||||
func didLockWalletSuccessfully(forAccount account: EthereumAccount, inCoordinator coordinator: ElevateWalletSecurityCoordinator) |
||||
func didCancelLock(forAccount account: EthereumAccount, inCoordinator coordinator: ElevateWalletSecurityCoordinator) |
||||
} |
||||
|
||||
class ElevateWalletSecurityCoordinator: Coordinator { |
||||
fileprivate struct Error: LocalizedError { |
||||
var errorDescription: String? |
||||
} |
||||
|
||||
private lazy var rootViewController: ElevateWalletSecurityViewController = { |
||||
let controller = ElevateWalletSecurityViewController(keystore: keystore, account: account) |
||||
controller.configure() |
||||
controller.delegate = self |
||||
return controller |
||||
}() |
||||
private let account: EthereumAccount |
||||
private let keystore: Keystore |
||||
|
||||
let navigationController: UINavigationController |
||||
var coordinators: [Coordinator] = [] |
||||
weak var delegate: ElevateWalletSecurityCoordinatorDelegate? |
||||
|
||||
init(navigationController: UINavigationController = UINavigationController(), keystore: Keystore, account: EthereumAccount) { |
||||
self.navigationController = navigationController |
||||
self.keystore = keystore |
||||
self.account = account |
||||
} |
||||
|
||||
func start() { |
||||
navigationController.pushViewController(rootViewController, animated: true) |
||||
} |
||||
|
||||
func end() { |
||||
//do nothing |
||||
} |
||||
|
||||
func endUserInterface(animated: Bool) { |
||||
navigationController.popViewController(animated: animated) |
||||
} |
||||
} |
||||
|
||||
extension ElevateWalletSecurityCoordinator: ElevateWalletSecurityViewControllerDelegate { |
||||
func didTapLock(inViewController viewController: ElevateWalletSecurityViewController) { |
||||
let isSuccessful = keystore.elevateSecurity(forAccount: account) |
||||
if isSuccessful { |
||||
delegate?.didLockWalletSuccessfully(forAccount: account, inCoordinator: self) |
||||
} else { |
||||
if keystore.isUserPresenceCheckPossible { |
||||
//do nothing. User cancelled |
||||
} else { |
||||
viewController.displayError(error: Error(errorDescription: R.string.localizable.keystoreAccessKeyLockFail())) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func didCancelLock(inViewController viewController: ElevateWalletSecurityViewController) { |
||||
delegate?.didCancelLock(forAccount: account, inCoordinator: self) |
||||
} |
||||
} |
@ -0,0 +1,63 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
protocol EnterPasswordCoordinatorDelegate: class { |
||||
func didEnterPassword(password: String, account: EthereumAccount, in coordinator: EnterPasswordCoordinator) |
||||
func didCancel(in coordinator: EnterPasswordCoordinator) |
||||
} |
||||
|
||||
class EnterPasswordCoordinator: CoordinatorThatEnds { |
||||
private lazy var rootViewController: KeystoreBackupIntroductionViewController = { |
||||
let controller = KeystoreBackupIntroductionViewController() |
||||
controller.delegate = self |
||||
controller.configure() |
||||
return controller |
||||
}() |
||||
private let account: EthereumAccount |
||||
|
||||
let navigationController: UINavigationController |
||||
var coordinators: [Coordinator] = [] |
||||
weak var delegate: EnterPasswordCoordinatorDelegate? |
||||
|
||||
init( |
||||
navigationController: UINavigationController = UINavigationController(), |
||||
account: EthereumAccount |
||||
) { |
||||
self.navigationController = navigationController |
||||
self.account = account |
||||
} |
||||
|
||||
func start() { |
||||
navigationController.pushViewController(rootViewController, animated: true) |
||||
} |
||||
|
||||
func end() { |
||||
//do nothing |
||||
} |
||||
|
||||
func endUserInterface(animated: Bool) { |
||||
navigationController.viewControllers.firstIndex(of: rootViewController) |
||||
.flatMap { navigationController.viewControllers[$0 - 1] } |
||||
.flatMap { navigationController.popToViewController($0, animated: animated) } |
||||
} |
||||
|
||||
func createEnterPasswordController() -> EnterPasswordViewController { |
||||
let controller = EnterPasswordViewController(account: account) |
||||
controller.delegate = self |
||||
return controller |
||||
} |
||||
} |
||||
|
||||
extension EnterPasswordCoordinator: KeystoreBackupIntroductionViewControllerDelegate { |
||||
func didTapExport(inViewController viewController: KeystoreBackupIntroductionViewController) { |
||||
navigationController.pushViewController(createEnterPasswordController(), animated: true) |
||||
} |
||||
} |
||||
|
||||
extension EnterPasswordCoordinator: EnterPasswordViewControllerDelegate { |
||||
func didEnterPassword(password: String, for account: EthereumAccount, inViewController viewController: EnterPasswordViewController) { |
||||
delegate?.didEnterPassword(password: password, account: account, in: self) |
||||
} |
||||
} |
@ -0,0 +1,47 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
protocol VerifySeedPhraseCoordinatorDelegate: class { |
||||
func didVerifySeedPhraseSuccessfully(forAccount account: EthereumAccount, inCoordinator coordinator: VerifySeedPhraseCoordinator) |
||||
} |
||||
|
||||
class VerifySeedPhraseCoordinator: Coordinator { |
||||
private lazy var rootViewController: VerifySeedPhraseViewController = { |
||||
let controller = VerifySeedPhraseViewController(keystore: keystore, account: account) |
||||
controller.configure() |
||||
controller.delegate = self |
||||
return controller |
||||
}() |
||||
private let account: EthereumAccount |
||||
private let keystore: Keystore |
||||
|
||||
let navigationController: UINavigationController |
||||
var coordinators: [Coordinator] = [] |
||||
weak var delegate: VerifySeedPhraseCoordinatorDelegate? |
||||
|
||||
init(navigationController: UINavigationController = UINavigationController(), keystore: Keystore, account: EthereumAccount) { |
||||
self.navigationController = navigationController |
||||
self.keystore = keystore |
||||
self.account = account |
||||
} |
||||
|
||||
func start() { |
||||
navigationController.pushViewController(rootViewController, animated: true) |
||||
} |
||||
|
||||
func end() { |
||||
//do nothing |
||||
} |
||||
|
||||
func endUserInterface(animated: Bool) { |
||||
navigationController.popViewController(animated: animated) |
||||
} |
||||
} |
||||
|
||||
extension VerifySeedPhraseCoordinator: VerifySeedPhraseViewControllerDelegate { |
||||
func didVerifySeedPhraseSuccessfully(for account: EthereumAccount, in viewController: VerifySeedPhraseViewController) { |
||||
delegate?.didVerifySeedPhraseSuccessfully(forAccount: account, inCoordinator: self) |
||||
} |
||||
} |
@ -0,0 +1,139 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import UIKit |
||||
|
||||
protocol CreateInitialWalletViewControllerDelegate: class { |
||||
func didTapCreateWallet(inViewController viewController: CreateInitialWalletViewController) |
||||
func didTapWatchWallet(inViewController viewController: CreateInitialWalletViewController) |
||||
func didTapImportWallet(inViewController viewController: CreateInitialWalletViewController) |
||||
} |
||||
|
||||
class CreateInitialWalletViewController: UIViewController { |
||||
private let keystore: Keystore |
||||
private var viewModel = CreateInitialViewModel() |
||||
private let roundedBackground = RoundedBackground() |
||||
private let subtitleLabel = UILabel() |
||||
private let imageView = UIImageView() |
||||
private let createWalletButtonBar = ButtonsBar(numberOfButtons: 1) |
||||
private let separator = UIView.spacer(height: 1) |
||||
private let haveWalletLabel = UILabel() |
||||
private let buttonsBar = ButtonsBar(numberOfButtons: 2) |
||||
|
||||
private var imageViewDimension: CGFloat { |
||||
if ScreenChecker().isNarrowScreen { |
||||
return 60 |
||||
} else { |
||||
return 90 |
||||
} |
||||
} |
||||
private var topMarginOfImageView: CGFloat { |
||||
if ScreenChecker().isNarrowScreen { |
||||
return 100 |
||||
} else { |
||||
return 170 |
||||
} |
||||
} |
||||
|
||||
weak var delegate: CreateInitialWalletViewControllerDelegate? |
||||
|
||||
init(keystore: Keystore) { |
||||
self.keystore = keystore |
||||
super.init(nibName: nil, bundle: nil) |
||||
|
||||
roundedBackground.translatesAutoresizingMaskIntoConstraints = false |
||||
view.addSubview(roundedBackground) |
||||
|
||||
imageView.contentMode = .scaleAspectFit |
||||
|
||||
let stackView = [ |
||||
UIView.spacer(height: topMarginOfImageView), |
||||
imageView, |
||||
UIView.spacer(height: 10), |
||||
subtitleLabel, |
||||
UIView.spacerWidth(flexible: true), |
||||
createWalletButtonBar, |
||||
UIView.spacer(height: 25), |
||||
separator, |
||||
UIView.spacer(height: 25), |
||||
haveWalletLabel, |
||||
UIView.spacer(height: 25), |
||||
].asStackView(axis: .vertical) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(stackView) |
||||
|
||||
let footerBar = UIView() |
||||
footerBar.translatesAutoresizingMaskIntoConstraints = false |
||||
footerBar.backgroundColor = .clear |
||||
roundedBackground.addSubview(footerBar) |
||||
|
||||
footerBar.addSubview(buttonsBar) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
imageView.heightAnchor.constraint(equalToConstant: imageViewDimension), |
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor), |
||||
stackView.bottomAnchor.constraint(equalTo: footerBar.topAnchor), |
||||
|
||||
createWalletButtonBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
buttonsBar.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor), |
||||
buttonsBar.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor), |
||||
buttonsBar.topAnchor.constraint(equalTo: footerBar.topAnchor), |
||||
buttonsBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
footerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||
footerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||
footerBar.topAnchor.constraint(equalTo: view.layoutGuide.bottomAnchor, constant: -ButtonsBar.buttonsHeight - ButtonsBar.marginAtBottomScreen), |
||||
footerBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
] + roundedBackground.createConstraintsWithContainer(view: view)) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
func configure() { |
||||
view.backgroundColor = Colors.appBackground |
||||
|
||||
subtitleLabel.textAlignment = .center |
||||
subtitleLabel.textColor = viewModel.subtitleColor |
||||
subtitleLabel.font = viewModel.subtitleFont |
||||
subtitleLabel.text = viewModel.subtitle |
||||
|
||||
imageView.image = viewModel.imageViewImage |
||||
|
||||
separator.backgroundColor = viewModel.separatorColor |
||||
|
||||
haveWalletLabel.textAlignment = .center |
||||
haveWalletLabel.textColor = viewModel.alreadyHaveWalletTextColor |
||||
haveWalletLabel.font = viewModel.alreadyHaveWalletTextFont |
||||
haveWalletLabel.text = viewModel.alreadyHaveWalletText |
||||
|
||||
createWalletButtonBar.configure() |
||||
let createWalletButton = createWalletButtonBar .buttons[0] |
||||
createWalletButton.setTitle(viewModel.createButtonTitle, for: .normal) |
||||
createWalletButton.addTarget(self, action: #selector(createWallet), for: .touchUpInside) |
||||
|
||||
buttonsBar.configureSecondary() |
||||
let watchButton = buttonsBar.buttons[0] |
||||
watchButton.setTitle(viewModel.watchButtonTitle, for: .normal) |
||||
watchButton.addTarget(self, action: #selector(watchWallet), for: .touchUpInside) |
||||
let importButton = buttonsBar.buttons[1] |
||||
importButton.setTitle(viewModel.importButtonTitle, for: .normal) |
||||
importButton.addTarget(self, action: #selector(importWallet), for: .touchUpInside) |
||||
} |
||||
|
||||
@objc private func createWallet() { |
||||
delegate?.didTapCreateWallet(inViewController: self) |
||||
} |
||||
|
||||
@objc private func watchWallet() { |
||||
delegate?.didTapWatchWallet(inViewController: self) |
||||
} |
||||
|
||||
@objc private func importWallet() { |
||||
delegate?.didTapImportWallet(inViewController: self) |
||||
} |
||||
} |
@ -0,0 +1,130 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import UIKit |
||||
|
||||
protocol ElevateWalletSecurityViewControllerDelegate: class { |
||||
func didTapLock(inViewController viewController: ElevateWalletSecurityViewController) |
||||
func didCancelLock(inViewController viewController: ElevateWalletSecurityViewController) |
||||
} |
||||
|
||||
class ElevateWalletSecurityViewController: UIViewController { |
||||
private let keystore: Keystore |
||||
private let account: EthereumAccount |
||||
lazy private var viewModel = ElevateWalletSecurityViewModel(isHdWallet: keystore.isHdWallet(account: account)) |
||||
private let roundedBackground = RoundedBackground() |
||||
private let subtitleLabel = UILabel() |
||||
private let imageView = UIImageView() |
||||
private let descriptionLabel = UILabel() |
||||
private let cancelButton = UIButton(type: .system) |
||||
private let buttonsBar = ButtonsBar(numberOfButtons: 1) |
||||
|
||||
private var imageViewDimension: CGFloat { |
||||
if ScreenChecker().isNarrowScreen { |
||||
return 180 |
||||
} else { |
||||
return 250 |
||||
} |
||||
} |
||||
|
||||
weak var delegate: ElevateWalletSecurityViewControllerDelegate? |
||||
|
||||
init(keystore: Keystore, account: EthereumAccount) { |
||||
self.keystore = keystore |
||||
self.account = account |
||||
super.init(nibName: nil, bundle: nil) |
||||
|
||||
hidesBottomBarWhenPushed = true |
||||
|
||||
roundedBackground.translatesAutoresizingMaskIntoConstraints = false |
||||
view.addSubview(roundedBackground) |
||||
|
||||
imageView.contentMode = .scaleAspectFit |
||||
|
||||
let stackView = [ |
||||
UIView.spacer(height: 30), |
||||
subtitleLabel, |
||||
UIView.spacer(height: 40), |
||||
imageView, |
||||
UIView.spacer(height: 40), |
||||
descriptionLabel, |
||||
].asStackView(axis: .vertical) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(stackView) |
||||
|
||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(cancelButton) |
||||
|
||||
let footerBar = UIView() |
||||
footerBar.translatesAutoresizingMaskIntoConstraints = false |
||||
footerBar.backgroundColor = .clear |
||||
roundedBackground.addSubview(footerBar) |
||||
|
||||
footerBar.addSubview(buttonsBar) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
imageView.heightAnchor.constraint(equalToConstant: imageViewDimension), |
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor), |
||||
stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor), |
||||
|
||||
cancelButton.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor, constant: 10), |
||||
cancelButton.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor, constant: -10), |
||||
cancelButton.bottomAnchor.constraint(equalTo: footerBar.topAnchor, constant: -20), |
||||
|
||||
buttonsBar.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor), |
||||
buttonsBar.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor), |
||||
buttonsBar.topAnchor.constraint(equalTo: footerBar.topAnchor), |
||||
buttonsBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
footerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||
footerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||
footerBar.topAnchor.constraint(equalTo: view.layoutGuide.bottomAnchor, constant: -ButtonsBar.buttonsHeight - ButtonsBar.marginAtBottomScreen), |
||||
footerBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
] + roundedBackground.createConstraintsWithContainer(view: view)) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
func configure() { |
||||
view.backgroundColor = Colors.appBackground |
||||
|
||||
title = viewModel.title |
||||
|
||||
subtitleLabel.textAlignment = .center |
||||
subtitleLabel.numberOfLines = 0 |
||||
subtitleLabel.textColor = viewModel.subtitleColor |
||||
subtitleLabel.font = viewModel.subtitleFont |
||||
subtitleLabel.text = viewModel.subtitle |
||||
|
||||
imageView.image = viewModel.imageViewImage |
||||
|
||||
descriptionLabel.textAlignment = .center |
||||
descriptionLabel.textColor = viewModel.descriptionColor |
||||
descriptionLabel.font = viewModel.descriptionFont |
||||
descriptionLabel.numberOfLines = 0 |
||||
descriptionLabel.text = viewModel.description |
||||
|
||||
cancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside) |
||||
cancelButton.setTitle(R.string.localizable.skip(), for: .normal) |
||||
cancelButton.titleLabel?.font = viewModel.cancelLockingButtonFont |
||||
cancelButton.titleLabel?.adjustsFontSizeToFitWidth = true |
||||
cancelButton.setTitleColor(viewModel.cancelLockingButtonTitleColor, for: .normal) |
||||
|
||||
buttonsBar.configure() |
||||
let exportButton = buttonsBar.buttons[0] |
||||
exportButton.setTitle(viewModel.title, for: .normal) |
||||
exportButton.addTarget(self, action: #selector(tappedLockButton), for: .touchUpInside) |
||||
} |
||||
|
||||
@objc private func tappedLockButton() { |
||||
delegate?.didTapLock(inViewController: self) |
||||
} |
||||
|
||||
@objc func cancel() { |
||||
delegate?.didCancelLock(inViewController: self) |
||||
} |
||||
} |
@ -1,45 +0,0 @@ |
||||
// Copyright SIX DAY LLC. All rights reserved. |
||||
|
||||
import Foundation |
||||
import TrustKeystore |
||||
|
||||
protocol EnterPasswordCoordinatorDelegate: class { |
||||
func didEnterPassword(password: String, account: Account, in coordinator: EnterPasswordCoordinator) |
||||
func didCancel(in coordinator: EnterPasswordCoordinator) |
||||
} |
||||
|
||||
class EnterPasswordCoordinator: Coordinator { |
||||
private lazy var enterPasswordController: EnterPasswordViewController = { |
||||
let controller = EnterPasswordViewController(account: account) |
||||
controller.navigationItem.leftBarButtonItem = UIBarButtonItem(title: R.string.localizable.cancel(), style: .plain, target: self, action: #selector(dismiss)) |
||||
controller.delegate = self |
||||
return controller |
||||
}() |
||||
private let account: Account |
||||
|
||||
let navigationController: UINavigationController |
||||
var coordinators: [Coordinator] = [] |
||||
weak var delegate: EnterPasswordCoordinatorDelegate? |
||||
|
||||
init( |
||||
navigationController: UINavigationController = UINavigationController(), |
||||
account: Account |
||||
) { |
||||
self.navigationController = navigationController |
||||
self.account = account |
||||
} |
||||
|
||||
func start() { |
||||
navigationController.viewControllers = [enterPasswordController] |
||||
} |
||||
|
||||
@objc func dismiss() { |
||||
delegate?.didCancel(in: self) |
||||
} |
||||
} |
||||
|
||||
extension EnterPasswordCoordinator: EnterPasswordViewControllerDelegate { |
||||
func didEnterPassword(password: String, for account: Account, in viewController: EnterPasswordViewController) { |
||||
delegate?.didEnterPassword(password: password, account: account, in: self) |
||||
} |
||||
} |
@ -0,0 +1,106 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import UIKit |
||||
|
||||
protocol KeystoreBackupIntroductionViewControllerDelegate: class { |
||||
func didTapExport(inViewController viewController: KeystoreBackupIntroductionViewController) |
||||
} |
||||
|
||||
class KeystoreBackupIntroductionViewController: UIViewController { |
||||
private var viewModel = KeystoreBackupIntroductionViewModel() |
||||
private let roundedBackground = RoundedBackground() |
||||
private let subtitleLabel = UILabel() |
||||
private let imageView = UIImageView() |
||||
private let descriptionLabel = UILabel() |
||||
private let buttonsBar = ButtonsBar(numberOfButtons: 1) |
||||
|
||||
private var imageViewDimension: CGFloat { |
||||
if ScreenChecker().isNarrowScreen { |
||||
return 180 |
||||
} else { |
||||
return 250 |
||||
} |
||||
} |
||||
|
||||
weak var delegate: KeystoreBackupIntroductionViewControllerDelegate? |
||||
|
||||
init() { |
||||
super.init(nibName: nil, bundle: nil) |
||||
|
||||
hidesBottomBarWhenPushed = true |
||||
|
||||
roundedBackground.translatesAutoresizingMaskIntoConstraints = false |
||||
view.addSubview(roundedBackground) |
||||
|
||||
imageView.contentMode = .scaleAspectFit |
||||
|
||||
let stackView = [ |
||||
UIView.spacer(height: 30), |
||||
subtitleLabel, |
||||
UIView.spacer(height: 40), |
||||
imageView, |
||||
UIView.spacer(height: 40), |
||||
descriptionLabel, |
||||
].asStackView(axis: .vertical) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(stackView) |
||||
|
||||
let footerBar = UIView() |
||||
footerBar.translatesAutoresizingMaskIntoConstraints = false |
||||
footerBar.backgroundColor = .clear |
||||
roundedBackground.addSubview(footerBar) |
||||
|
||||
footerBar.addSubview(buttonsBar) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
imageView.heightAnchor.constraint(equalToConstant: imageViewDimension), |
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor), |
||||
stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor), |
||||
|
||||
buttonsBar.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor), |
||||
buttonsBar.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor), |
||||
buttonsBar.topAnchor.constraint(equalTo: footerBar.topAnchor), |
||||
buttonsBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
footerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||
footerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||
footerBar.topAnchor.constraint(equalTo: view.layoutGuide.bottomAnchor, constant: -ButtonsBar.buttonsHeight - ButtonsBar.marginAtBottomScreen), |
||||
footerBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
] + roundedBackground.createConstraintsWithContainer(view: view)) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
func configure() { |
||||
view.backgroundColor = Colors.appBackground |
||||
|
||||
title = viewModel.title |
||||
|
||||
subtitleLabel.textAlignment = .center |
||||
subtitleLabel.textColor = viewModel.subtitleColor |
||||
subtitleLabel.font = viewModel.subtitleFont |
||||
subtitleLabel.text = viewModel.subtitle |
||||
|
||||
imageView.image = viewModel.imageViewImage |
||||
|
||||
descriptionLabel.textAlignment = .center |
||||
descriptionLabel.textColor = viewModel.descriptionColor |
||||
descriptionLabel.font = viewModel.descriptionFont |
||||
descriptionLabel.numberOfLines = 0 |
||||
descriptionLabel.text = viewModel.description |
||||
|
||||
buttonsBar.configure() |
||||
let exportButton = buttonsBar.buttons[0] |
||||
exportButton.setTitle(viewModel.title, for: .normal) |
||||
exportButton.addTarget(self, action: #selector(tappedExportButton), for: .touchUpInside) |
||||
} |
||||
|
||||
@objc private func tappedExportButton() { |
||||
delegate?.didTapExport(inViewController: self) |
||||
} |
||||
} |
@ -0,0 +1,226 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import UIKit |
||||
|
||||
protocol ShowSeedPhraseViewControllerDelegate: class { |
||||
func didTapTestSeedPhrase(for account: EthereumAccount, inViewController viewController: ShowSeedPhraseViewController) |
||||
func didClose(for account: EthereumAccount, inViewController viewController: ShowSeedPhraseViewController) |
||||
} |
||||
|
||||
//We must be careful to no longer show the seedphrase and remove it from memory when this screen is hidden because another VC is displayed over it or because the device is locked |
||||
class ShowSeedPhraseViewController: UIViewController { |
||||
private enum State { |
||||
case notDisplayedSeedPhrase |
||||
case displayingSeedPhrase(words: [String]) |
||||
case errorDisplaySeedPhrase(KeystoreError) |
||||
case done |
||||
} |
||||
|
||||
private var viewModel: ShowSeedPhraseViewModel { |
||||
didSet { |
||||
seedPhraseCollectionView.viewModel = .init(words: viewModel.words, shouldShowSequenceNumber: true) |
||||
} |
||||
} |
||||
private let keystore: Keystore |
||||
private let account: EthereumAccount |
||||
private let roundedBackground = RoundedBackground() |
||||
private let subtitleLabel = UILabel() |
||||
private let descriptionLabel = UILabel() |
||||
private let errorLabel = UILabel() |
||||
private var state: State = .notDisplayedSeedPhrase { |
||||
didSet { |
||||
switch state { |
||||
case .notDisplayedSeedPhrase: |
||||
viewModel = .init(words: []) |
||||
case .displayingSeedPhrase(let words): |
||||
viewModel = .init(words: words) |
||||
case .errorDisplaySeedPhrase(let error): |
||||
viewModel = .init(error: error) |
||||
case .done: |
||||
viewModel = .init(words: []) |
||||
} |
||||
configure() |
||||
} |
||||
} |
||||
private let seedPhraseCollectionView = SeedPhraseCollectionView() |
||||
private let buttonsBar = ButtonsBar(numberOfButtons: 1) |
||||
private var notDisplayingSeedPhrase: Bool { |
||||
switch state { |
||||
case .notDisplayedSeedPhrase: |
||||
return true |
||||
case .displayingSeedPhrase: |
||||
return false |
||||
case .errorDisplaySeedPhrase: |
||||
return false |
||||
case .done: |
||||
return false |
||||
} |
||||
} |
||||
private var isDone: Bool { |
||||
switch state { |
||||
case .notDisplayedSeedPhrase: |
||||
return false |
||||
case .displayingSeedPhrase: |
||||
return false |
||||
case .errorDisplaySeedPhrase: |
||||
return false |
||||
case .done: |
||||
return true |
||||
} |
||||
} |
||||
//We have this flag because when prompted for Touch ID/Face ID, the app becomes inactive, and the order is: |
||||
//1. we read the seed, thus the prompt shows up, making the app inactive |
||||
//2. user authenticates and we get the seed |
||||
//3. app is now notified as inactive! (note that this is after authentication succeeds) |
||||
//4. app becomes active |
||||
//Without this flag, we will be removing the seed in (3) and trying to read it in (4) again and triggering (1), thus going into an infinite loop of reading |
||||
private var isInactiveBecauseWeAccessingBiometrics = false |
||||
|
||||
weak var delegate: ShowSeedPhraseViewControllerDelegate? |
||||
|
||||
init(keystore: Keystore, account: EthereumAccount) { |
||||
self.keystore = keystore |
||||
self.account = account |
||||
self.viewModel = .init(words: []) |
||||
super.init(nibName: nil, bundle: nil) |
||||
|
||||
hidesBottomBarWhenPushed = true |
||||
|
||||
roundedBackground.translatesAutoresizingMaskIntoConstraints = false |
||||
view.addSubview(roundedBackground) |
||||
|
||||
let stackView = [ |
||||
UIView.spacer(height: 30), |
||||
subtitleLabel, |
||||
descriptionLabel, |
||||
UIView.spacer(height: 10), |
||||
errorLabel, |
||||
UIView.spacer(height: 50), |
||||
seedPhraseCollectionView, |
||||
].asStackView(axis: .vertical) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(stackView) |
||||
|
||||
let footerBar = UIView() |
||||
footerBar.translatesAutoresizingMaskIntoConstraints = false |
||||
footerBar.backgroundColor = .clear |
||||
roundedBackground.addSubview(footerBar) |
||||
|
||||
footerBar.addSubview(buttonsBar) |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor), |
||||
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
|
||||
buttonsBar.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor), |
||||
buttonsBar.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor), |
||||
buttonsBar.topAnchor.constraint(equalTo: footerBar.topAnchor), |
||||
buttonsBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
footerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||
footerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||
footerBar.topAnchor.constraint(equalTo: view.layoutGuide.bottomAnchor, constant: -ButtonsBar.buttonsHeight - ButtonsBar.marginAtBottomScreen), |
||||
footerBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
] + roundedBackground.createConstraintsWithContainer(view: view)) |
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appWillResignsActive), name: UIApplication.willResignActiveNotification, object: nil) |
||||
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) |
||||
NotificationCenter.default.addObserver(self, selector: #selector(didTakeScreenShot), name: UIApplication.userDidTakeScreenshotNotification, object: nil) |
||||
|
||||
hidesBottomBarWhenPushed = true |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
override func viewDidDisappear(_ animated: Bool) { |
||||
super.viewDidDisappear(animated) |
||||
if isMovingFromParent || isBeingDismissed { |
||||
delegate?.didClose(for: account, inViewController: self) |
||||
return |
||||
} |
||||
removeSeedPhraseFromDisplay() |
||||
} |
||||
|
||||
override func viewDidAppear(_ animated: Bool) { |
||||
super.viewDidAppear(animated) |
||||
showSeedPhrases() |
||||
} |
||||
|
||||
@objc private func appWillResignsActive() { |
||||
if isInactiveBecauseWeAccessingBiometrics { |
||||
isInactiveBecauseWeAccessingBiometrics = false |
||||
return |
||||
} |
||||
removeSeedPhraseFromDisplay() |
||||
} |
||||
|
||||
@objc private func appDidBecomeActive() { |
||||
showSeedPhrases() |
||||
} |
||||
|
||||
@objc private func didTakeScreenShot() { |
||||
displaySuccess(message: R.string.localizable.walletsShowSeedPhraseDoNotTakeScreenshotDescription()) |
||||
} |
||||
|
||||
private func showSeedPhrases() { |
||||
guard !isDone else { return } |
||||
guard isTopViewController else { return } |
||||
guard notDisplayingSeedPhrase else { return } |
||||
isInactiveBecauseWeAccessingBiometrics = true |
||||
keystore.exportSeedPhraseOfHdWallet(forAccount: account, reason: .backup) { result in |
||||
switch result { |
||||
case .success(let words): |
||||
self.state = .displayingSeedPhrase(words: words.split(separator: " ").map { String($0) }) |
||||
case .failure(let error): |
||||
self.state = .errorDisplaySeedPhrase(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func configure() { |
||||
view.backgroundColor = Colors.appBackground |
||||
|
||||
title = viewModel.title |
||||
|
||||
subtitleLabel.textAlignment = .center |
||||
subtitleLabel.textColor = viewModel.subtitleColor |
||||
subtitleLabel.font = viewModel.subtitleFont |
||||
//Important for smaller screens |
||||
subtitleLabel.numberOfLines = 0 |
||||
subtitleLabel.text = viewModel.subtitle |
||||
|
||||
descriptionLabel.textAlignment = .center |
||||
descriptionLabel.textColor = viewModel.descriptionColor |
||||
descriptionLabel.font = viewModel.descriptionFont |
||||
descriptionLabel.numberOfLines = 0 |
||||
descriptionLabel.text = viewModel.description |
||||
|
||||
errorLabel.textColor = viewModel.errorColor |
||||
errorLabel.font = viewModel.errorFont |
||||
errorLabel.text = viewModel.errorMessage |
||||
|
||||
seedPhraseCollectionView.configure() |
||||
|
||||
buttonsBar.configure() |
||||
let testSeedPhraseButton = buttonsBar.buttons[0] |
||||
testSeedPhraseButton.setTitle(R.string.localizable.walletsShowSeedPhraseTestSeedPhrase(), for: .normal) |
||||
testSeedPhraseButton.addTarget(self, action: #selector(testSeedPhrase), for: .touchUpInside) |
||||
} |
||||
|
||||
@objc private func testSeedPhrase() { |
||||
delegate?.didTapTestSeedPhrase(for: account, inViewController: self) |
||||
} |
||||
|
||||
private func removeSeedPhraseFromDisplay() { |
||||
guard !isDone else { return } |
||||
state = .notDisplayedSeedPhrase |
||||
} |
||||
|
||||
func markDone() { |
||||
state = .done |
||||
} |
||||
} |
@ -0,0 +1,314 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import UIKit |
||||
|
||||
protocol VerifySeedPhraseViewControllerDelegate: class { |
||||
func didVerifySeedPhraseSuccessfully(for account: EthereumAccount, in viewController: VerifySeedPhraseViewController) |
||||
} |
||||
|
||||
class VerifySeedPhraseViewController: UIViewController { |
||||
private enum State { |
||||
case editingSeedPhrase(words: [String]) |
||||
case seedPhraseNotMatched(words: [String]) |
||||
case seedPhraseMatched(words: [String]) |
||||
case keystoreError(KeystoreError) |
||||
case notDisplayedSeedPhrase |
||||
case errorDisplaySeedPhrase(KeystoreError) |
||||
|
||||
var words: [String] { |
||||
switch self { |
||||
case .editingSeedPhrase(let words), .seedPhraseMatched(let words), .seedPhraseNotMatched(let words): |
||||
return words |
||||
case .keystoreError, .notDisplayedSeedPhrase, .errorDisplaySeedPhrase: |
||||
return .init() |
||||
} |
||||
} |
||||
} |
||||
|
||||
private var viewModel: VerifySeedPhraseViewModel |
||||
private let keystore: Keystore |
||||
private let account: EthereumAccount |
||||
private let roundedBackground = RoundedBackground() |
||||
private let seedPhraseTextView = UITextView() |
||||
private let seedPhraseCollectionView = SeedPhraseCollectionView() |
||||
private let errorLabel = UILabel() |
||||
private let clearChooseSeedPhraseButton = UIButton(type: .system) |
||||
private let buttonsBar = ButtonsBar(numberOfButtons: 1) |
||||
private var state: State { |
||||
didSet { |
||||
switch state { |
||||
case .editingSeedPhrase(let words): |
||||
seedPhraseCollectionView.viewModel = .init(words: words, isSelectable: true) |
||||
clearError() |
||||
case .seedPhraseMatched(let words): |
||||
seedPhraseCollectionView.viewModel = .init(words: words, isSelectable: true) |
||||
errorLabel.text = viewModel.noErrorText |
||||
errorLabel.textColor = viewModel.noErrorColor |
||||
seedPhraseTextView.borderColor = viewModel.seedPhraseTextViewBorderNormalColor |
||||
delegate?.didVerifySeedPhraseSuccessfully(for: account, in: self) |
||||
case .seedPhraseNotMatched(let words): |
||||
errorLabel.text = R.string.localizable.walletsVerifySeedPhraseWrong() |
||||
errorLabel.textColor = viewModel.errorColor |
||||
seedPhraseTextView.borderColor = viewModel.seedPhraseTextViewBorderErrorColor |
||||
case .keystoreError(let error): |
||||
seedPhraseCollectionView.viewModel = .init(words: [], isSelectable: true) |
||||
errorLabel.text = error.errorDescription |
||||
errorLabel.textColor = viewModel.errorColor |
||||
seedPhraseTextView.borderColor = viewModel.seedPhraseTextViewBorderErrorColor |
||||
case .notDisplayedSeedPhrase: |
||||
seedPhraseCollectionView.viewModel = .init(words: [], isSelectable: true) |
||||
case .errorDisplaySeedPhrase(let error): |
||||
seedPhraseCollectionView.viewModel = .init(words: [], isSelectable: true) |
||||
errorLabel.text = error.errorDescription |
||||
errorLabel.textColor = viewModel.errorColor |
||||
seedPhraseTextView.borderColor = viewModel.seedPhraseTextViewBorderErrorColor |
||||
} |
||||
} |
||||
} |
||||
private var notDisplayingSeedPhrase: Bool { |
||||
switch state { |
||||
case .editingSeedPhrase: |
||||
return false |
||||
case .seedPhraseMatched: |
||||
return false |
||||
case .seedPhraseNotMatched: |
||||
return false |
||||
case .keystoreError(let error): |
||||
return false |
||||
case .notDisplayedSeedPhrase: |
||||
return true |
||||
case .errorDisplaySeedPhrase: |
||||
return false |
||||
} |
||||
|
||||
} |
||||
//We have this flag because when prompted for Touch ID/Face ID, the app becomes inactive, and the order is: |
||||
//1. we read the seed, thus the prompt shows up, making the app inactive |
||||
//2. user authenticates and we get the seed |
||||
//3. app is now notified as inactive! (note that this is after authentication succeeds) |
||||
//4. app becomes active |
||||
//Without this flag, we will be removing the seed in (3) and trying to read it in (4) again and triggering (1), thus going into an infinite loop of reading |
||||
private var isInactiveBecauseWeAccessingBiometrics = false |
||||
|
||||
weak var delegate: VerifySeedPhraseViewControllerDelegate? |
||||
|
||||
init(keystore: Keystore, account: EthereumAccount) { |
||||
self.keystore = keystore |
||||
self.account = account |
||||
self.viewModel = .init() |
||||
self.state = .notDisplayedSeedPhrase |
||||
super.init(nibName: nil, bundle: nil) |
||||
|
||||
seedPhraseCollectionView.seedPhraseDelegate = self |
||||
|
||||
roundedBackground.translatesAutoresizingMaskIntoConstraints = false |
||||
view.addSubview(roundedBackground) |
||||
|
||||
seedPhraseTextView.isEditable = false |
||||
//Disable copying |
||||
seedPhraseTextView.isUserInteractionEnabled = false |
||||
seedPhraseTextView.delegate = self |
||||
|
||||
let stackView = [ |
||||
UIView.spacer(height: 30), |
||||
seedPhraseTextView, |
||||
UIView.spacer(height: 7), |
||||
errorLabel, |
||||
UIView.spacer(height: 30), |
||||
seedPhraseCollectionView, |
||||
].asStackView(axis: .vertical) |
||||
stackView.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(stackView) |
||||
|
||||
clearChooseSeedPhraseButton.isHidden = true |
||||
clearChooseSeedPhraseButton.translatesAutoresizingMaskIntoConstraints = false |
||||
roundedBackground.addSubview(clearChooseSeedPhraseButton) |
||||
|
||||
let footerBar = UIView() |
||||
footerBar.translatesAutoresizingMaskIntoConstraints = false |
||||
footerBar.backgroundColor = .clear |
||||
roundedBackground.addSubview(footerBar) |
||||
|
||||
footerBar.addSubview(buttonsBar) |
||||
|
||||
seedPhraseTextView.becomeFirstResponder() |
||||
|
||||
NSLayoutConstraint.activate([ |
||||
seedPhraseTextView.heightAnchor.constraint(equalToConstant: 140), |
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), |
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), |
||||
stackView.topAnchor.constraint(equalTo: view.topAnchor), |
||||
stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
|
||||
clearChooseSeedPhraseButton.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor, constant: 10), |
||||
clearChooseSeedPhraseButton.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor, constant: -10), |
||||
clearChooseSeedPhraseButton.bottomAnchor.constraint(equalTo: footerBar.topAnchor, constant: -20), |
||||
|
||||
buttonsBar.leadingAnchor.constraint(equalTo: footerBar.leadingAnchor), |
||||
buttonsBar.trailingAnchor.constraint(equalTo: footerBar.trailingAnchor), |
||||
buttonsBar.topAnchor.constraint(equalTo: footerBar.topAnchor), |
||||
buttonsBar.heightAnchor.constraint(equalToConstant: ButtonsBar.buttonsHeight), |
||||
|
||||
footerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), |
||||
footerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), |
||||
footerBar.topAnchor.constraint(equalTo: view.layoutGuide.bottomAnchor, constant: -ButtonsBar.buttonsHeight - ButtonsBar.marginAtBottomScreen), |
||||
footerBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), |
||||
] + roundedBackground.createConstraintsWithContainer(view: view)) |
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(appWillResignsActive), name: UIApplication.willResignActiveNotification, object: nil) |
||||
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) |
||||
NotificationCenter.default.addObserver(self, selector: #selector(didTakeScreenShot), name: UIApplication.userDidTakeScreenshotNotification, object: nil) |
||||
} |
||||
|
||||
required init?(coder aDecoder: NSCoder) { |
||||
fatalError("init(coder:) has not been implemented") |
||||
} |
||||
|
||||
override func viewDidDisappear(_ animated: Bool) { |
||||
super.viewDidDisappear(animated) |
||||
if isMovingFromParent || isBeingDismissed { |
||||
return |
||||
} |
||||
removeSeedPhraseFromDisplay() |
||||
} |
||||
|
||||
override func viewDidAppear(_ animated: Bool) { |
||||
super.viewDidAppear(animated) |
||||
showSeedPhrases() |
||||
} |
||||
|
||||
@objc private func appDidBecomeActive() { |
||||
showSeedPhrases() |
||||
} |
||||
|
||||
@objc private func didTakeScreenShot() { |
||||
displaySuccess(message: R.string.localizable.walletsVerifySeedPhraseDoNotTakeScreenshotDescription()) |
||||
} |
||||
|
||||
@objc private func appWillResignsActive() { |
||||
if isInactiveBecauseWeAccessingBiometrics { |
||||
isInactiveBecauseWeAccessingBiometrics = false |
||||
return |
||||
} |
||||
removeSeedPhraseFromDisplay() |
||||
} |
||||
|
||||
private func showSeedPhrases() { |
||||
guard isTopViewController else { return } |
||||
guard notDisplayingSeedPhrase else { return } |
||||
isInactiveBecauseWeAccessingBiometrics = true |
||||
keystore.exportSeedPhraseOfHdWallet(forAccount: account, reason: .prepareForVerification) { result in |
||||
switch result { |
||||
case .success(let words): |
||||
self.state = .editingSeedPhrase(words: words.split(separator: " ").map { String($0) }.shuffled()) |
||||
case .failure(let error): |
||||
self.state = .errorDisplaySeedPhrase(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func configure() { |
||||
view.backgroundColor = Colors.appBackground |
||||
|
||||
title = viewModel.title |
||||
|
||||
seedPhraseTextView.keyboardType = .alphabet |
||||
seedPhraseTextView.returnKeyType = .done |
||||
seedPhraseTextView.autocapitalizationType = .none |
||||
seedPhraseTextView.autocorrectionType = .no |
||||
seedPhraseTextView.enablesReturnKeyAutomatically = true |
||||
seedPhraseTextView.borderColor = viewModel.seedPhraseTextViewBorderNormalColor |
||||
seedPhraseTextView.borderWidth = viewModel.seedPhraseTextViewBorderWidth |
||||
seedPhraseTextView.cornerRadius = viewModel.seedPhraseTextViewBorderCornerRadius |
||||
seedPhraseTextView.font = viewModel.seedPhraseTextViewFont |
||||
seedPhraseTextView.contentInset = viewModel.seedPhraseTextViewContentInset |
||||
|
||||
errorLabel.textColor = viewModel.noErrorColor |
||||
errorLabel.text = viewModel.noErrorText |
||||
errorLabel.font = viewModel.errorFont |
||||
errorLabel.numberOfLines = 0 |
||||
|
||||
seedPhraseCollectionView.configure() |
||||
|
||||
clearChooseSeedPhraseButton.addTarget(self, action: #selector(clearChosenSeedPhrases), for: .touchUpInside) |
||||
clearChooseSeedPhraseButton.setTitle(R.string.localizable.clearButtonTitle(), for: .normal) |
||||
clearChooseSeedPhraseButton.titleLabel?.font = viewModel.importKeystoreJsonButtonFont |
||||
clearChooseSeedPhraseButton.titleLabel?.adjustsFontSizeToFitWidth = true |
||||
|
||||
buttonsBar.configure() |
||||
let continueButton = buttonsBar.buttons[0] |
||||
continueButton.setTitle(R.string.localizable.walletsVerifySeedPhraseTitle(), for: .normal) |
||||
continueButton.addTarget(self, action: #selector(verify), for: .touchUpInside) |
||||
} |
||||
|
||||
@objc func clearChosenSeedPhrases() { |
||||
seedPhraseTextView.text = "" |
||||
seedPhraseCollectionView.viewModel.clearSelectedWords() |
||||
clearChooseSeedPhraseButton.isHidden = true |
||||
state = .editingSeedPhrase(words: state.words) |
||||
} |
||||
|
||||
@objc func verify() { |
||||
isInactiveBecauseWeAccessingBiometrics = true |
||||
keystore.verifySeedPhraseOfHdWallet(seedPhraseTextView.text.lowercased().trimmed, forAccount: account) { result in |
||||
switch result { |
||||
case .success(let isMatched): |
||||
//Safety precaution, we clear the seed phrase. The next screen may be the prompt to elevate security of wallet screen which the user can go back from |
||||
self.clearChosenSeedPhrases() |
||||
self.updateStateWithVerificationResult(isMatched) |
||||
case .failure(let error): |
||||
self.reflectError(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func updateStateWithVerificationResult(_ isMatched: Bool) { |
||||
if isMatched { |
||||
state = .seedPhraseMatched(words: state.words) |
||||
} else { |
||||
state = .seedPhraseNotMatched(words: state.words) |
||||
} |
||||
} |
||||
|
||||
private func reflectError(_ error: KeystoreError) { |
||||
state = .keystoreError(error) |
||||
} |
||||
|
||||
private func removeSeedPhraseFromDisplay() { |
||||
state = .notDisplayedSeedPhrase |
||||
} |
||||
|
||||
private func clearError() { |
||||
errorLabel.text = viewModel.noErrorText |
||||
errorLabel.textColor = viewModel.noErrorColor |
||||
seedPhraseTextView.borderColor = viewModel.seedPhraseTextViewBorderNormalColor |
||||
} |
||||
} |
||||
|
||||
|
||||
extension VerifySeedPhraseViewController: UITextViewDelegate { |
||||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { |
||||
if text == "\n" { |
||||
verify() |
||||
seedPhraseTextView.resignFirstResponder() |
||||
return false |
||||
} else { |
||||
state = .editingSeedPhrase(words: state.words) |
||||
return true |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension VerifySeedPhraseViewController: SeedPhraseCollectionViewDelegate { |
||||
func didTap(word: String, atIndex index: Int, inCollectionView: SeedPhraseCollectionView) { |
||||
if seedPhraseTextView.text.isEmpty { |
||||
seedPhraseTextView.text += word |
||||
} else { |
||||
seedPhraseTextView.text += " \(word)" |
||||
} |
||||
clearChooseSeedPhraseButton.isHidden = false |
||||
clearError() |
||||
} |
||||
} |
@ -0,0 +1,58 @@ |
||||
// Copyright © 2019 Stormbird PTE. LTD. |
||||
|
||||
import Foundation |
||||
import UIKit |
||||
|
||||
struct CreateInitialViewModel { |
||||
var backgroundColor: UIColor { |
||||
return Colors.appWhite |
||||
} |
||||
|
||||
var subtitle: String { |
||||
return R.string.localizable.gettingStartedSubtitle() |
||||
} |
||||
|
||||
var subtitleColor: UIColor { |
||||
return Colors.appText |
||||
} |
||||
|
||||
var subtitleFont: UIFont { |
||||
if ScreenChecker().isNarrowScreen { |
||||
return Fonts.regular(size: 20)! |
||||
} else { |
||||
return Fonts.regular(size: 30)! |
||||
} |
||||
} |
||||
|
||||
var imageViewImage: UIImage { |
||||
return R.image.launch_icon()! |
||||
} |
||||
|
||||
var createButtonTitle: String { |
||||
return R.string.localizable.gettingStartedNewWallet() |
||||
} |
||||
|
||||
var watchButtonTitle: String { |
||||
return R.string.localizable.watch() |
||||
} |
||||
|
||||
var importButtonTitle: String { |
||||
return R.string.localizable.importWalletImportButtonTitle() |
||||
} |
||||
|
||||
var alreadyHaveWalletText: String { |
||||
return R.string.localizable.gettingStartedAlreadyHaveWallet() |
||||
} |
||||
|
||||
var alreadyHaveWalletTextColor: UIColor { |
||||
return Colors.appText |
||||
} |
||||
|
||||
var alreadyHaveWalletTextFont: UIFont { |
||||
return Fonts.regular(size: 18)! |
||||
} |
||||
|
||||
var separatorColor: UIColor { |
||||
return .init(red: 235, green: 235, blue: 235) |
||||
} |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue