// // SecureKeyStore.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Custom API-key vault — deliberately NOT the macOS Keychain. // // Design: // • Vault file ~/Library/Application Support/ZyquoCloud/vault.zq // layout: [salt 32B][AES-GCM nonce 12B][ciphertext+tag] // plaintext: JSON dictionary {"openai": "sk-…", "anthropic": "sk-ant-…", …} // • Master key HKDF-SHA256(ikm: machineEntropy ‖ pepper, salt: vault salt, // info: "ZyquoCloud.vault.v1") → AES-256-GCM key. // machineEntropy = IOPlatformUUID (IOKit) ‖ user home path — binds the // vault to this machine and account. // • Pepper: compiled-in, assembled at runtime from obfuscated fragments — // never a plain string literal in the binary. // // Keys are decrypted only on demand, never logged, never written to disk in // plaintext, and redacted to their last 4 characters everywhere in the UI. // import CryptoKit import Foundation import IOKit import Security struct SecureKeyStore { enum VaultError: LocalizedError { case corrupted case machineIdentityUnavailable var errorDescription: String? { switch self { case .corrupted: return "The key vault is damaged or belongs to another machine." case .machineIdentityUnavailable: return "Could not read this Mac's hardware identity." } } } private static let saltLength = 32 private static let keyLength = 32 let vaultURL: URL /// Overridable for tests; defaults to real machine identity. private let machineEntropy: () throws -> Data init( vaultURL: URL? = nil, machineEntropy: (() throws -> Data)? = nil ) { let root = PersistenceService.shared.rootDirectory self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq") self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy } // MARK: - Public API /// All stored keys (provider rawValue → API key). Empty if no vault exists. func loadKeys() throws -> [String: String] { guard let blob = try? Data(contentsOf: vaultURL) else { return [:] } guard blob.count > Self.saltLength + 12 + 16 else { throw VaultError.corrupted } let salt = blob.prefix(Self.saltLength) let rest = blob.dropFirst(Self.saltLength) let key = try masterKey(salt: salt) do { let box = try AES.GCM.SealedBox(combined: rest) let plaintext = try AES.GCM.open(box, using: key) return try JSONDecoder().decode([String: String].self, from: plaintext) } catch { throw VaultError.corrupted } } /// Encrypts and atomically writes the full key dictionary. func saveKeys(_ keys: [String: String]) throws { let salt = try existingSalt() ?? Self.randomBytes(Self.saltLength) let key = try masterKey(salt: salt) let plaintext = try JSONEncoder().encode(keys) let box = try AES.GCM.seal(plaintext, using: key) guard let combined = box.combined else { throw VaultError.corrupted } var blob = Data(salt) blob.append(combined) try FileManager.default.createDirectory( at: vaultURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection]) } func key(for provider: ProviderID) throws -> String? { try loadKeys()[provider.rawValue] } func setKey(_ apiKey: String, for provider: ProviderID) throws { var keys = try loadKeys() keys[provider.rawValue] = apiKey try saveKeys(keys) } func deleteKey(for provider: ProviderID) throws { var keys = try loadKeys() keys.removeValue(forKey: provider.rawValue) try saveKeys(keys) } /// "••••…abcd" display form. Never show more. static func redacted(_ apiKey: String) -> String { let suffix = apiKey.suffix(4) return "••••\(suffix)" } // MARK: - Key derivation private func masterKey(salt: Data) throws -> SymmetricKey { var ikm = try machineEntropy() ikm.append(Self.pepper()) return HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: ikm), salt: salt, info: Data("ZyquoCloud.vault.v1".utf8), outputByteCount: Self.keyLength ) } /// Hardware UUID + home path. Binds the vault to machine + account. private static func defaultMachineEntropy() throws -> Data { guard let uuid = platformUUID() else { throw VaultError.machineIdentityUnavailable } var entropy = Data(uuid.utf8) entropy.append(Data(NSHomeDirectory().utf8)) return entropy } /// IOPlatformUUID from the IOPlatformExpertDevice registry entry. private static func platformUUID() -> String? { let service = IOServiceGetMatchingService( kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice") ) guard service != IO_OBJECT_NULL else { return nil } defer { IOObjectRelease(service) } guard let property = IORegistryEntryCreateCFProperty( service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0 ) else { return nil } return property.takeRetainedValue() as? String } /// App pepper, assembled at runtime — the constants below are the pepper /// bytes XOR 0x5A so the value never appears verbatim in the binary. private static func pepper() -> Data { let obfuscated: [UInt8] = [ 0x10, 0x23, 0x2B, 0x2F, 0x35, 0x79, 0x36, 0x35, 0x2F, 0x3E, 0x77, 0x28, 0x3B, 0x33, 0x34, 0x78, 0x39, 0x36, 0x35, 0x2F, 0x3E, 0x69, 0x6E, 0x68, 0x6C, 0x0E, 0x3B, 0x28, 0x3F, 0x08, ] return Data(obfuscated.map { $0 ^ 0x5A }) } private static func randomBytes(_ count: Int) throws -> Data { var bytes = [UInt8](repeating: 0, count: count) let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) guard status == errSecSuccess else { throw VaultError.corrupted } return Data(bytes) } /// Salt of the existing vault, so re-saving keeps the same derivation. private func existingSalt() throws -> Data? { guard let blob = try? Data(contentsOf: vaultURL), blob.count >= Self.saltLength else { return nil } return blob.prefix(Self.saltLength) } }