spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// SecureKeyStore.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Custom API-key vault — deliberately NOT the macOS Keychain. Same design and9// blob format as Zyquo Cloud's vault; only the HKDF info string and pepper10// differ, so the two apps keep separate, mutually-undecryptable vaults with an11// identical user experience (docs/PROVIDER-REUSE.md §4).12//13// Design:14// • Vault file ~/Library/Application Support/ZyquoAgent/vault.zq15// layout: [salt 32B][AES-GCM nonce 12B][ciphertext+tag]16// plaintext: JSON dictionary {"openai": "sk-…", "anthropic": "sk-ant-…", …}17// • Master key HKDF-SHA256(ikm: machineEntropy ‖ pepper, salt: vault salt,18// info: "ZyquoAgent.vault.v1") → AES-256-GCM key.19// machineEntropy = IOPlatformUUID (IOKit) ‖ user home path — binds the20// vault to this machine and account.21// • Pepper: compiled-in, assembled at runtime from obfuscated fragments —22// never a plain string literal in the binary.23//24// Keys are decrypted only on demand, never logged, never written to disk in25// plaintext, and redacted to their last 4 characters everywhere in the UI.26//2728import CryptoKit29import Foundation30import IOKit31import Security3233struct SecureKeyStore {34 enum VaultError: LocalizedError {35 case corrupted36 case machineIdentityUnavailable3738 var errorDescription: String? {39 switch self {40 case .corrupted:41 return "The key vault is damaged or belongs to another machine."42 case .machineIdentityUnavailable:43 return "Could not read this Mac's hardware identity."44 }45 }46 }4748 private static let saltLength = 3249 private static let keyLength = 325051 let vaultURL: URL52 /// Overridable for tests; defaults to real machine identity.53 private let machineEntropy: () throws -> Data5455 init(56 vaultURL: URL? = nil,57 machineEntropy: (() throws -> Data)? = nil58 ) {59 let root = PersistenceService.shared.rootDirectory60 self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq")61 self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy62 }6364 // MARK: - Public API6566 /// All stored keys (provider rawValue → API key). Empty if no vault exists.67 func loadKeys() throws -> [String: String] {68 guard let blob = try? Data(contentsOf: vaultURL) else { return [:] }69 guard blob.count > Self.saltLength + 12 + 16 else { throw VaultError.corrupted }70 let salt = blob.prefix(Self.saltLength)71 let rest = blob.dropFirst(Self.saltLength)72 let key = try masterKey(salt: salt)73 do {74 let box = try AES.GCM.SealedBox(combined: rest)75 let plaintext = try AES.GCM.open(box, using: key)76 return try JSONDecoder().decode([String: String].self, from: plaintext)77 } catch {78 throw VaultError.corrupted79 }80 }8182 /// Encrypts and atomically writes the full key dictionary.83 func saveKeys(_ keys: [String: String]) throws {84 let salt = try existingSalt() ?? Self.randomBytes(Self.saltLength)85 let key = try masterKey(salt: salt)86 let plaintext = try JSONEncoder().encode(keys)87 let box = try AES.GCM.seal(plaintext, using: key)88 guard let combined = box.combined else { throw VaultError.corrupted }89 var blob = Data(salt)90 blob.append(combined)91 try FileManager.default.createDirectory(92 at: vaultURL.deletingLastPathComponent(), withIntermediateDirectories: true93 )94 try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection])95 }9697 func key(for provider: ProviderID) throws -> String? {98 try loadKeys()[provider.rawValue]99 }100101 func setKey(_ apiKey: String, for provider: ProviderID) throws {102 var keys = try loadKeys()103 keys[provider.rawValue] = apiKey104 try saveKeys(keys)105 }106107 func deleteKey(for provider: ProviderID) throws {108 var keys = try loadKeys()109 keys.removeValue(forKey: provider.rawValue)110 try saveKeys(keys)111 }112113 /// "••••…abcd" display form. Never show more.114 static func redacted(_ apiKey: String) -> String {115 let suffix = apiKey.suffix(4)116 return "••••\(suffix)"117 }118119 // MARK: - Key derivation120121 private func masterKey(salt: Data) throws -> SymmetricKey {122 var ikm = try machineEntropy()123 ikm.append(Self.pepper())124 return HKDF<SHA256>.deriveKey(125 inputKeyMaterial: SymmetricKey(data: ikm),126 salt: salt,127 info: Data("ZyquoAgent.vault.v1".utf8),128 outputByteCount: Self.keyLength129 )130 }131132 /// Hardware UUID + home path. Binds the vault to machine + account.133 private static func defaultMachineEntropy() throws -> Data {134 guard let uuid = platformUUID() else { throw VaultError.machineIdentityUnavailable }135 var entropy = Data(uuid.utf8)136 entropy.append(Data(NSHomeDirectory().utf8))137 return entropy138 }139140 /// IOPlatformUUID from the IOPlatformExpertDevice registry entry.141 private static func platformUUID() -> String? {142 let service = IOServiceGetMatchingService(143 kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")144 )145 guard service != IO_OBJECT_NULL else { return nil }146 defer { IOObjectRelease(service) }147 guard let property = IORegistryEntryCreateCFProperty(148 service, kIOPlatformUUIDKey as CFString, kCFAllocatorDefault, 0149 ) else { return nil }150 return property.takeRetainedValue() as? String151 }152153 /// App pepper, assembled at runtime — the constants below are the pepper154 /// bytes XOR 0x5A so the value never appears verbatim in the binary.155 /// (Distinct from Zyquo Cloud's pepper by design.)156 private static func pepper() -> Data {157 let obfuscated: [UInt8] = [158 0x00, 0x23, 0x2B, 0x2F, 0x35, 0x7A, 0x1B, 0x3D, 0x3F, 0x34,159 0x2E, 0x7A, 0x2C, 0x3B, 0x2F, 0x36, 0x2E, 0x7A, 0x2A, 0x3F,160 0x2A, 0x2A, 0x3F, 0x28, 0x7A, 0x2C, 0x6B, 0x7A, 0x09, 0x18,161 ]162 return Data(obfuscated.map { $0 ^ 0x5A })163 }164165 private static func randomBytes(_ count: Int) throws -> Data {166 var bytes = [UInt8](repeating: 0, count: count)167 let status = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)168 guard status == errSecSuccess else { throw VaultError.corrupted }169 return Data(bytes)170 }171172 /// Salt of the existing vault, so re-saving keeps the same derivation.173 private func existingSalt() throws -> Data? {174 guard let blob = try? Data(contentsOf: vaultURL),175 blob.count >= Self.saltLength else { return nil }176 return blob.prefix(Self.saltLength)177 }178}179