SPB Git

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%
16.8 KB · 436 lines swift
Raw Blame History
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