spb/os-vault Public
Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.
Swift 96%
Shell 3.4%
Makefile 0.6%
1import WalletCoreC2import Foundation3// Copyright © 2017-2018 Trust.4//5// This file is part of Trust. The full Trust copyright notice, including6// terms governing use, modification, and redistribution, is contained in the7// file LICENSE at the root of the source code distribution tree.89import Foundation1011/// Manages directories of key and wallet files and presents them as accounts.12public final class KeyStore {13 static let watchesFileName = "watches.json"1415 /// The key file directory.16 public let keyDirectory: URL1718 /// The watches file URL.19 public let watchesFile: URL2021 /// List of wallets.22 public private(set) var wallets = [Wallet]()2324 /// List of accounts being watched25 public var watches = [Watch]()2627 /// Creates a `KeyStore` for the given directory.28 public init(keyDirectory: URL) throws {29 self.keyDirectory = keyDirectory30 self.watchesFile = keyDirectory.appendingPathComponent(KeyStore.watchesFileName)3132 try load()33 }3435 private func load() throws {36 let fileManager = FileManager.default37 try? fileManager.createDirectory(at: keyDirectory, withIntermediateDirectories: true, attributes: nil)3839 if fileManager.fileExists(atPath: watchesFile.path) {40 let data = try Data(contentsOf: watchesFile)41 watches = try JSONDecoder().decode([Watch].self, from: data)42 }4344 let accountURLs = try fileManager.contentsOfDirectory(at: keyDirectory, includingPropertiesForKeys: [], options: [.skipsHiddenFiles])45 for url in accountURLs {46 if url.lastPathComponent == KeyStore.watchesFileName {47 // Skip watches file48 continue49 }50 guard let key = StoredKey.load(path: url.path) else {51 // Ignore invalid keys52 continue53 }54 let wallet = Wallet(keyURL: url, key: key)55 wallets.append(wallet)56 }57 }5859 /// Watches a list of accounts.60 public func watch(_ watches: [Watch]) throws {61 self.watches.append(contentsOf: watches)6263 let data = try JSONEncoder().encode(watches)64 try data.write(to: watchesFile)65 }6667 /// Stop watching an account.68 public func removeWatch(_ watch: Watch) throws {69 guard let index = watches.firstIndex(of: watch) else {70 return71 }72 watches.remove(at: index)7374 let data = try JSONEncoder().encode(watches)75 try data.write(to: watchesFile)76 }7778 /// Creates a new wallet. HD default by default79 public func createWallet(name: String, password: String, coins: [CoinType], encryption: StoredKeyEncryption = .aes128Ctr) throws -> Wallet {80 let key = StoredKey(name: name, password: Data(password.utf8), encryption: encryption)81 return try saveCreatedWallet(for: key, password: password, coins: coins)82 }8384 private func saveCreatedWallet(for key: StoredKey, password: String, coins: [CoinType]) throws -> Wallet {85 let url = makeAccountURL()86 let wallet = Wallet(keyURL: url, key: key)87 for coin in coins {88 _ = try wallet.getAccount(password: password, coin: coin)89 }90 wallets.append(wallet)9192 try save(wallet: wallet)9394 return wallet95 }9697 /// Adds accounts to a wallet.98 public func addAccounts(wallet: Wallet, coins: [CoinType], password: String) throws -> [Account] {99 let accounts = try wallet.getAccounts(password: password, coins: coins)100 try save(wallet: wallet)101 return accounts102 }103104 /// Remove accounts from a wallet.105 public func removeAccounts(wallet: Wallet, coins: [CoinType], password: String) throws -> Wallet {106 guard wallet.key.decryptPrivateKey(password: Data(password.utf8)) != nil else {107 throw Error.invalidPassword108 }109110 guard let index = wallets.firstIndex(of: wallet) else {111 fatalError("Missing wallet")112 }113114 for coin in coins {115 wallet.key.removeAccountForCoin(coin: coin)116 }117118 wallets[index] = wallet119 try save(wallet: wallet)120 return wallet121 }122123 /// Imports an encrypted JSON key.124 ///125 /// - Parameters:126 /// - json: json wallet127 /// - password: key password128 /// - newPassword: password to use for the imported key129 /// - coins: coins to use for this wallet130 /// - Returns: new account131 public func `import`(json: Data, name: String, password: String, newPassword: String, coins: [CoinType]) throws -> Wallet {132 guard let key = StoredKey.importJSON(json: json) else {133 throw Error.invalidJSON134 }135 guard let data = key.decryptPrivateKey(password: Data(password.utf8)) else {136 throw Error.invalidPassword137 }138139 if let mnemonic = checkMnemonic(data) {140 return try self.import(mnemonic: mnemonic, name: name, encryptPassword: newPassword, coins: coins)141 }142143 guard let privateKey = PrivateKey(data: data) else {144 throw Error.invalidKey145 }146 if key.hasPrivateKeyEncoded {147 guard let encodedPrivateKey = key.decryptPrivateKeyEncoded(password: Data(password.utf8)) else {148 throw Error.invalidPassword149 }150 return try self.import(encodedPrivateKey: encodedPrivateKey, name: name, password: newPassword, coin: coins.first ?? .ethereum)151 } else {152 return try self.import(privateKey: privateKey, name: name, password: newPassword, coin: coins.first ?? .ethereum)153 }154 }155156 private func checkMnemonic(_ data: Data) -> String? {157 guard let mnemonic = String(data: data, encoding: .ascii), Mnemonic.isValid(mnemonic: mnemonic) else {158 return nil159 }160 return mnemonic161 }162 163 private func checkEncoded(wallet: Wallet, password: String) -> String? {164 guard wallet.key.hasPrivateKeyEncoded else {165 return nil166 }167 return wallet.key.decryptPrivateKeyEncoded(password: Data(password.utf8))168 }169170 /// Imports a private key.171 ///172 /// - Parameters:173 /// - privateKey: private key to import174 /// - password: password to use for the imported private key175 /// - coin: coin to use for this wallet176 /// - Returns: new wallet177 public func `import`(privateKey: PrivateKey, name: String, password: String, coin: CoinType, encryption: StoredKeyEncryption = .aes128Ctr) throws -> Wallet {178 guard let newKey = StoredKey.importPrivateKeyWithEncryption(privateKey: privateKey.data, name: name, password: Data(password.utf8), coin: coin, encryption: encryption) else {179 throw Error.invalidKey180 }181 let url = makeAccountURL()182 let wallet = Wallet(keyURL: url, key: newKey)183 _ = try wallet.getAccount(password: password, coin: coin)184 wallets.append(wallet)185186 try save(wallet: wallet)187188 return wallet189 }190191 /// Imports an encoded private key.192 ///193 /// - Parameters:194 /// - privateKey: private key to import195 /// - password: password to use for the imported private key196 /// - coin: coin to use for this wallet197 /// - Returns: new wallet198 public func `import`(encodedPrivateKey: String, name: String, password: String, coin: CoinType, encryption: StoredKeyEncryption = .aes128Ctr) throws -> Wallet {199 guard let newKey = StoredKey.importPrivateKeyEncodedWithEncryption(privateKey: encodedPrivateKey, name: name, password: Data(password.utf8), coin: coin, encryption: encryption) else {200 throw Error.invalidKey201 }202 let url = makeAccountURL()203 let wallet = Wallet(keyURL: url, key: newKey)204 _ = try wallet.getAccount(password: password, coin: coin)205 wallets.append(wallet)206207 try save(wallet: wallet)208209 return wallet210 }211212 /// Imports a wallet.213 ///214 /// - Parameters:215 /// - mnemonic: wallet's mnemonic phrase216 /// - encryptPassword: password to use for encrypting217 /// - coins: coins to add218 /// - Returns: new account219 public func `import`(mnemonic: String, name: String, encryptPassword: String, coins: [CoinType], encryption: StoredKeyEncryption = .aes128Ctr) throws -> Wallet {220 guard let key = StoredKey.importHDWalletWithEncryption(mnemonic: mnemonic, name: name, password: Data(encryptPassword.utf8), coin: coins.first ?? .ethereum, encryption: encryption) else {221 throw Error.invalidMnemonic222 }223 let url = makeAccountURL()224 let wallet = Wallet(keyURL: url, key: key)225 _ = try wallet.getAccounts(password: encryptPassword, coins: coins)226227 wallets.append(wallet)228229 try save(wallet: wallet)230231 return wallet232 }233234 /// Exports a wallet as JSON data.235 ///236 /// - Parameters:237 /// - wallet: wallet to export238 /// - password: account password239 /// - newPassword: password to use for exported key240 /// - Returns: encrypted JSON key241 public func export(wallet: Wallet, password: String, newPassword: String, encryption: StoredKeyEncryption = .aes128Ctr) throws -> Data {242 var privateKeyData = try exportPrivateKey(wallet: wallet, password: password)243 defer {244 privateKeyData.resetBytes(in: 0 ..< privateKeyData.count)245 }246247 guard let coin = wallet.key.account(index: 0)?.coin else {248 throw Error.accountNotFound249 }250251 if let mnemonic = checkMnemonic(privateKeyData), let newKey = StoredKey.importHDWalletWithEncryption(mnemonic: mnemonic, name: "", password: Data(newPassword.utf8), coin: coin, encryption: encryption) {252 guard let json = newKey.exportJSON() else {253 throw Error.invalidKey254 }255 return json256 } else if let privateKey = checkEncoded(wallet: wallet, password: password), let newKey = StoredKey.importPrivateKeyEncodedWithEncryption(privateKey: privateKey, name: "", password: Data(newPassword.utf8), coin: coin, encryption: encryption) {257 guard let json = newKey.exportJSON() else {258 throw Error.invalidKey259 }260 return json261 } else if let newKey = StoredKey.importPrivateKeyWithEncryption(privateKey: privateKeyData, name: "", password: Data(newPassword.utf8), coin: coin, encryption: encryption) {262 guard let json = newKey.exportJSON() else {263 throw Error.invalidKey264 }265 return json266 }267268 throw Error.invalidKey269 }270271 /// Exports a wallet as private key data.272 ///273 /// - Parameters:274 /// - wallet: wallet to export275 /// - password: account password276 /// - Returns: private key data for encrypted keys or mnemonic phrase for HD wallets277 public func exportPrivateKey(wallet: Wallet, password: String) throws -> Data {278 guard let key = wallet.key.decryptPrivateKey(password: Data(password.utf8)) else {279 throw Error.invalidPassword280 }281 return key282 }283 284 /// Exports a wallet as encoded private key data.285 ///286 /// - Parameters:287 /// - wallet: wallet to export288 /// - password: account password289 /// - Returns: encoded private key data290 public func exportPrivateKeyEncoded(wallet: Wallet, password: String) throws -> String {291 guard let key = wallet.key.decryptPrivateKeyEncoded(password: Data(password.utf8)) else {292 throw Error.invalidPassword293 }294 return key295 }296297 /// Exports a wallet as a mnemonic phrase.298 ///299 /// - Parameters:300 /// - wallet: wallet to export301 /// - password: account password302 /// - Returns: mnemonic phrase303 /// - Throws: `EncryptError.invalidMnemonic` if the account is not an HD wallet.304 public func exportMnemonic(wallet: Wallet, password: String) throws -> String {305 guard let mnemonic = wallet.key.decryptMnemonic(password: Data(password.utf8)) else {306 throw Error.invalidPassword307 }308 return mnemonic309 }310311 /// Updates the password of an existing account.312 ///313 /// - Parameters:314 /// - wallet: wallet to update315 /// - password: current password316 /// - newPassword: new password317 public func update(wallet: Wallet, password: String, newPassword: String) throws {318 try update(wallet: wallet, password: password, newPassword: newPassword, newName: wallet.key.name)319 }320321 /// Updates the name of an existing account.322 ///323 /// - Parameters:324 /// - wallet: wallet to update325 /// - password: current password326 /// - newName: new name327 public func update(wallet: Wallet, password: String, newName: String, encryption: StoredKeyEncryption = .aes128Ctr) throws {328 try update(wallet: wallet, password: password, newPassword: password, newName: newName, encryption: encryption)329 }330331 private func update(wallet: Wallet, password: String, newPassword: String, newName: String, encryption: StoredKeyEncryption = .aes128Ctr) throws {332 guard let index = wallets.firstIndex(of: wallet) else {333 fatalError("Missing wallet")334 }335336 guard var privateKeyData = wallet.key.decryptPrivateKey(password: Data(password.utf8)) else {337 throw Error.invalidPassword338 }339 defer {340 privateKeyData.resetBytes(in: 0 ..< privateKeyData.count)341 }342343 let coins = wallet.accounts.map({ $0.coin })344 guard !coins.isEmpty else {345 throw Error.accountNotFound346 }347348 if let mnemonic = checkMnemonic(privateKeyData),349 let key = StoredKey.importHDWalletWithEncryption(mnemonic: mnemonic, name: newName, password: Data(newPassword.utf8), coin: coins[0], encryption: encryption) {350 wallets[index].key = key351 } else if let key = StoredKey.importPrivateKeyWithEncryption(352 privateKey: privateKeyData, name: newName, password: Data(newPassword.utf8), coin: coins[0], encryption: encryption) {353 wallets[index].key = key354 } else {355 throw Error.invalidKey356 }357358 _ = try wallets[index].getAccounts(password: newPassword, coins: coins)359 try save(wallet: wallets[index])360 }361362 /// Deletes an account including its key if the password is correct.363 public func delete(wallet: Wallet, password: String) throws {364 guard let index = wallets.firstIndex(of: wallet) else {365 fatalError("Missing wallet")366 }367368 guard var privateKey = wallet.key.decryptPrivateKey(password: Data(password.utf8)) else {369 throw KeyStore.Error.invalidKey370 }371 defer {372 privateKey.resetBytes(in: 0..<privateKey.count)373 }374 wallets.remove(at: index)375376 try FileManager.default.removeItem(at: wallet.keyURL)377 }378379 /// Removes all wallets.380 public func destroy() throws {381 wallets.removeAll(keepingCapacity: false)382383 let fileManager = FileManager.default384 let accountURLs = try fileManager.contentsOfDirectory(at: keyDirectory, includingPropertiesForKeys: [], options: [.skipsHiddenFiles])385 for url in accountURLs {386 try? fileManager.removeItem(at: url)387 }388 }389390 // MARK: Helpers391392 private func makeAccountURL(for address: Address) -> URL {393 return keyDirectory.appendingPathComponent(generateFileName(identifier: address.description))394 }395396 private func makeAccountURL() -> URL {397 return keyDirectory.appendingPathComponent(generateFileName(identifier: UUID().uuidString))398 }399400 private func generateTempFileURL(accountURL: URL) -> URL {401 return accountURL.deletingLastPathComponent()402 .appendingPathComponent(accountURL.lastPathComponent + "." + UUID().uuidString + ".tmp")403 }404405 private func save(wallet: Wallet) throws {406 let tempFilePath = generateTempFileURL(accountURL: wallet.keyURL).path407 guard wallet.key.storeWithTemporaryFile(path: wallet.keyURL.path, temporaryPath: tempFilePath) else {408 throw Error.storageFailed409 }410 }411412 /// Generates a unique file name for an address.413 func generateFileName(identifier: String, date: Date = Date(), timeZone: TimeZone = .current) -> String {414 // keyFileName implements the naming convention for keyfiles:415 // UTC--<created_at UTC ISO8601>-<address hex>416 return "UTC--\(filenameTimestamp(for: date, in: timeZone))--\(identifier)"417 }418419 private func filenameTimestamp(for date: Date, in timeZone: TimeZone = .current) -> String {420 var tz = ""421 let offset = timeZone.secondsFromGMT()422 if offset == 0 {423 tz = "Z"424 } else {425 tz = String(format: "%03d00", offset/60)426 }427428 let components = Calendar(identifier: .iso8601).dateComponents(in: timeZone, from: date)429 return String(format: "%04d-%02d-%02dT%02d-%02d-%02d.%09d%@",430 components.year!, components.month!,431 components.day!, components.hour!,432 components.minute!, components.second!,433 components.nanosecond!, tz)434 }435}436