SPB Git

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