// // KeyManager.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import Web3Core import BigInt /// Wallet lifecycle: BIP-39 mnemonic generation/import, HD derivation at /// m/44'/60'/0'/0/0, and persistence through the encrypted vault file /// (VaultCrypto — no Keychain, no iCloud, never leaves this Mac). /// /// The private key is derived on demand (unlock / send / export) and returned /// to the caller; KeyManager itself never retains key material. public final class KeyManager { public static let derivationPath = "m/44'/60'/0'/0/0" public struct UnlockedWallet { public let address: String // EIP-55 checksummed public let privateKey: Data public let mnemonic: String } /// What the vault ciphertext protects. struct VaultPayload: Codable { let mnemonic: String let derivationPath: String } public let vaultURL: URL public init(vaultURL: URL? = nil) { if let vaultURL { self.vaultURL = vaultURL } else { let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.vaultURL = support.appendingPathComponent("OSVault/vault.json") } } public var hasVault: Bool { FileManager.default.fileExists(atPath: vaultURL.path) } // MARK: - Create / import public func generateMnemonic() throws -> String { guard let mnemonic = try? BIP39.generateMnemonics(bitsOfEntropy: 128, language: .english), !mnemonic.isEmpty else { throw WalletError.internalError("Mnemonic generation failed.") } return mnemonic } public static func validate(mnemonic: String) -> Bool { let words = normalize(mnemonic: mnemonic).split(separator: " ") guard words.count == 12 || words.count == 24 else { return false } return BIP39.mnemonicsToEntropy(normalize(mnemonic: mnemonic), language: .english) != nil } /// Persists the mnemonic into a fresh encrypted vault. Same path for /// "create" (with a just-generated mnemonic) and "import". @discardableResult public func saveWallet(mnemonic: String, password: String) throws -> UnlockedWallet { let wallet = try Self.derive(mnemonic: mnemonic) let payload = VaultPayload(mnemonic: wallet.mnemonic, derivationPath: Self.derivationPath) let secret = try JSONEncoder().encode(payload) let vault = try VaultCrypto.seal(secret: secret, password: password) let dir = vaultURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(vault) try data.write(to: vaultURL, options: [.atomic]) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: vaultURL.path) return wallet } // MARK: - Unlock / export / delete public func unlock(password: String) throws -> UnlockedWallet { guard hasVault else { throw WalletError.noVault } let data: Data do { data = try Data(contentsOf: vaultURL) } catch { throw WalletError.vaultCorrupted } guard let vault = try? JSONDecoder().decode(VaultCrypto.VaultFile.self, from: data) else { throw WalletError.vaultCorrupted } let secret = try VaultCrypto.open(vault, password: password) guard let payload = try? JSONDecoder().decode(VaultPayload.self, from: secret) else { throw WalletError.vaultCorrupted } return try Self.derive(mnemonic: payload.mnemonic) } public func exportMnemonic(password: String) throws -> String { try unlock(password: password).mnemonic } public func deleteVault() throws { guard hasVault else { return } try FileManager.default.removeItem(at: vaultURL) } // MARK: - Derivation public static func derive(mnemonic: String) throws -> UnlockedWallet { let normalized = normalize(mnemonic: mnemonic) guard validate(mnemonic: normalized), let seed = BIP39.seedFromMmemonics(normalized, password: "", language: .english), let root = HDNode(seed: seed), let node = root.derive(path: derivationPath, derivePrivateKey: true), let privateKey = node.privateKey, let publicKey = Utilities.privateToPublic(privateKey, compressed: false), let address = Utilities.publicToAddress(publicKey) else { throw WalletError.invalidMnemonic } guard let checksummed = EthereumAddress.toChecksumAddress(address.address) else { throw WalletError.internalError("Address derivation failed.") } return UnlockedWallet(address: checksummed, privateKey: privateKey, mnemonic: normalized) } static func normalize(mnemonic: String) -> String { mnemonic .lowercased() .components(separatedBy: .whitespacesAndNewlines) .filter { !$0.isEmpty } .joined(separator: " ") } }