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%
1//2// KeyManager.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import Web3Core11import BigInt1213/// Wallet lifecycle: BIP-39 mnemonic generation/import, HD derivation at14/// m/44'/60'/0'/0/0, and persistence through the encrypted vault file15/// (VaultCrypto — no Keychain, no iCloud, never leaves this Mac).16///17/// The private key is derived on demand (unlock / send / export) and returned18/// to the caller; KeyManager itself never retains key material.19public final class KeyManager {2021 public static let derivationPath = "m/44'/60'/0'/0/0"2223 public struct UnlockedWallet {24 public let address: String // EIP-55 checksummed25 public let privateKey: Data26 public let mnemonic: String27 }2829 /// What the vault ciphertext protects.30 struct VaultPayload: Codable {31 let mnemonic: String32 let derivationPath: String33 }3435 public let vaultURL: URL3637 public init(vaultURL: URL? = nil) {38 if let vaultURL {39 self.vaultURL = vaultURL40 } else {41 let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]42 self.vaultURL = support.appendingPathComponent("OSVault/vault.json")43 }44 }4546 public var hasVault: Bool {47 FileManager.default.fileExists(atPath: vaultURL.path)48 }4950 // MARK: - Create / import5152 public func generateMnemonic() throws -> String {53 guard let mnemonic = try? BIP39.generateMnemonics(bitsOfEntropy: 128, language: .english),54 !mnemonic.isEmpty else {55 throw WalletError.internalError("Mnemonic generation failed.")56 }57 return mnemonic58 }5960 public static func validate(mnemonic: String) -> Bool {61 let words = normalize(mnemonic: mnemonic).split(separator: " ")62 guard words.count == 12 || words.count == 24 else { return false }63 return BIP39.mnemonicsToEntropy(normalize(mnemonic: mnemonic), language: .english) != nil64 }6566 /// Persists the mnemonic into a fresh encrypted vault. Same path for67 /// "create" (with a just-generated mnemonic) and "import".68 @discardableResult69 public func saveWallet(mnemonic: String, password: String) throws -> UnlockedWallet {70 let wallet = try Self.derive(mnemonic: mnemonic)71 let payload = VaultPayload(mnemonic: wallet.mnemonic, derivationPath: Self.derivationPath)72 let secret = try JSONEncoder().encode(payload)73 let vault = try VaultCrypto.seal(secret: secret, password: password)7475 let dir = vaultURL.deletingLastPathComponent()76 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)77 let encoder = JSONEncoder()78 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]79 let data = try encoder.encode(vault)80 try data.write(to: vaultURL, options: [.atomic])81 try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: vaultURL.path)82 return wallet83 }8485 // MARK: - Unlock / export / delete8687 public func unlock(password: String) throws -> UnlockedWallet {88 guard hasVault else { throw WalletError.noVault }89 let data: Data90 do { data = try Data(contentsOf: vaultURL) } catch { throw WalletError.vaultCorrupted }91 guard let vault = try? JSONDecoder().decode(VaultCrypto.VaultFile.self, from: data) else {92 throw WalletError.vaultCorrupted93 }94 let secret = try VaultCrypto.open(vault, password: password)95 guard let payload = try? JSONDecoder().decode(VaultPayload.self, from: secret) else {96 throw WalletError.vaultCorrupted97 }98 return try Self.derive(mnemonic: payload.mnemonic)99 }100101 public func exportMnemonic(password: String) throws -> String {102 try unlock(password: password).mnemonic103 }104105 public func deleteVault() throws {106 guard hasVault else { return }107 try FileManager.default.removeItem(at: vaultURL)108 }109110 // MARK: - Derivation111112 public static func derive(mnemonic: String) throws -> UnlockedWallet {113 let normalized = normalize(mnemonic: mnemonic)114 guard validate(mnemonic: normalized),115 let seed = BIP39.seedFromMmemonics(normalized, password: "", language: .english),116 let root = HDNode(seed: seed),117 let node = root.derive(path: derivationPath, derivePrivateKey: true),118 let privateKey = node.privateKey,119 let publicKey = Utilities.privateToPublic(privateKey, compressed: false),120 let address = Utilities.publicToAddress(publicKey) else {121 throw WalletError.invalidMnemonic122 }123 guard let checksummed = EthereumAddress.toChecksumAddress(address.address) else {124 throw WalletError.internalError("Address derivation failed.")125 }126 return UnlockedWallet(address: checksummed, privateKey: privateKey, mnemonic: normalized)127 }128129 static func normalize(mnemonic: String) -> String {130 mnemonic131 .lowercased()132 .components(separatedBy: .whitespacesAndNewlines)133 .filter { !$0.isEmpty }134 .joined(separator: " ")135 }136}137