SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
6.5 KB · 175 lines swift
Raw Blame History
1//2//  SecureKeyStore.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Custom API-key vault — deliberately NOT the macOS Keychain.9//10//  Design:11//    • Vault file  ~/Library/Application Support/ZyquoCloud/vault.zq12//      layout: [salt 32B][AES-GCM nonce 12B][ciphertext+tag]13//      plaintext: JSON dictionary {"openai": "sk-…", "anthropic": "sk-ant-…", …}14//    • Master key  HKDF-SHA256(ikm: machineEntropy ‖ pepper, salt: vault salt,15//      info: "ZyquoCloud.vault.v1") → AES-256-GCM key.16//      machineEntropy = IOPlatformUUID (IOKit) ‖ user home path — binds the17//      vault to this machine and account.18//    • Pepper: compiled-in, assembled at runtime from obfuscated fragments —19//      never a plain string literal in the binary.20//21//  Keys are decrypted only on demand, never logged, never written to disk in22//  plaintext, and redacted to their last 4 characters everywhere in the UI.23//2425import CryptoKit26import Foundation27import IOKit28import Security2930struct SecureKeyStore {31    enum VaultError: LocalizedError {32        case corrupted33        case machineIdentityUnavailable3435        var errorDescription: String? {36            switch self {37            case .corrupted:38                return "The key vault is damaged or belongs to another machine."39            case .machineIdentityUnavailable:40                return "Could not read this Mac's hardware identity."41            }42        }43    }4445    private static let saltLength = 3246    private static let keyLength = 324748    let vaultURL: URL49    /// Overridable for tests; defaults to real machine identity.50    private let machineEntropy: () throws -> Data5152    init(53        vaultURL: URL? = nil,54        machineEntropy: (() throws -> Data)? = nil55    ) {56        let root = PersistenceService.shared.rootDirectory57        self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq")58        self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy59    }6061    // MARK: - Public API6263    /// All stored keys (provider rawValue → API key). Empty if no vault exists.64    func loadKeys() throws -> [String: String] {65        guard let blob = try? Data(contentsOf: vaultURL) else { return [:] }66        guard blob.count > Self.saltLength + 12 + 16 else { throw VaultError.corrupted }67        let salt = blob.prefix(Self.saltLength)68        let rest = blob.dropFirst(Self.saltLength)69        let key = try masterKey(salt: salt)70        do {71            let box = try AES.GCM.SealedBox(combined: rest)72            let plaintext = try AES.GCM.open(box, using: key)73            return try JSONDecoder().decode([String: String].self, from: plaintext)74        } catch {75            throw VaultError.corrupted76        }77    }7879    /// Encrypts and atomically writes the full key dictionary.80    func saveKeys(_ keys: [String: String]) throws {81        let salt = try existingSalt() ?? Self.randomBytes(Self.saltLength)82        let key = try masterKey(salt: salt)83        let plaintext = try JSONEncoder().encode(keys)84        let box = try AES.GCM.seal(plaintext, using: key)85        guard let combined = box.combined else { throw VaultError.corrupted }86        var blob = Data(salt)87        blob.append(combined)88        try FileManager.default.createDirectory(89            at: vaultURL.deletingLastPathComponent(), withIntermediateDirectories: true90        )91        try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection])92    }9394    func key(for provider: ProviderID) throws -> String? {95        try loadKeys()[provider.rawValue]96    }9798    func setKey(_ apiKey: String, for provider: ProviderID) throws {99        var keys = try loadKeys()100        keys[provider.rawValue] = apiKey101        try saveKeys(keys)102    }103104    func deleteKey(for provider: ProviderID) throws {105        var keys = try loadKeys()106        keys.removeValue(forKey: provider.rawValue)107        try saveKeys(keys)108    }109110    /// "••••…abcd" display form. Never show more.111    static func redacted(_ apiKey: String) -> String {112        let suffix = apiKey.suffix(4)113        return "••••\(suffix)"114    }115116    // MARK: - Key derivation117118    private func masterKey(salt: Data) throws -> SymmetricKey {119        var ikm = try machineEntropy()120        ikm.append(Self.pepper())121        return HKDF<SHA256>.deriveKey(122            inputKeyMaterial: SymmetricKey(data: ikm),123            salt: salt,124            info: Data("ZyquoCloud.vault.v1".utf8),125            outputByteCount: Self.keyLength126        )127    }128129    /// Hardware UUID + home path. Binds the vault to machine + account.130    private static func defaultMachineEntropy() throws -> Data {131        guard let uuid = platformUUID() else { throw VaultError.machineIdentityUnavailable }132        var entropy = Data(uuid.utf8)133        entropy.append(Data(NSHomeDirectory().utf8))134        return entropy135    }136137    /// IOPlatformUUID from the IOPlatformExpertDevice registry entry.138    private static func platformUUID() -> String? {139        let service = IOServiceGetMatchingService(140            kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")141        )142        guard service != IO_OBJECT_NULL else { return nil }143        defer { IOObjectRelease(service) }144        guard let property = IORegistryEntryCreateCFProperty(145            service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0146        ) else { return nil }147        return property.takeRetainedValue() as? String148    }149150    /// App pepper, assembled at runtime — the constants below are the pepper151    /// bytes XOR 0x5A so the value never appears verbatim in the binary.152    private static func pepper() -> Data {153        let obfuscated: [UInt8] = [154            0x10, 0x23, 0x2B, 0x2F, 0x35, 0x79, 0x36, 0x35, 0x2F, 0x3E,155            0x77, 0x28, 0x3B, 0x33, 0x34, 0x78, 0x39, 0x36, 0x35, 0x2F,156            0x3E, 0x69, 0x6E, 0x68, 0x6C, 0x0E, 0x3B, 0x28, 0x3F, 0x08,157        ]158        return Data(obfuscated.map { $0 ^ 0x5A })159    }160161    private static func randomBytes(_ count: Int) throws -> Data {162        var bytes = [UInt8](repeating: 0, count: count)163        let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)164        guard status == errSecSuccess else { throw VaultError.corrupted }165        return Data(bytes)166    }167168    /// Salt of the existing vault, so re-saving keeps the same derivation.169    private func existingSalt() throws -> Data? {170        guard let blob = try? Data(contentsOf: vaultURL),171              blob.count >= Self.saltLength else { return nil }172        return blob.prefix(Self.saltLength)173    }174}175