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