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%
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