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%

phase3: SecureKeyStore — AES-256-GCM vault, HKDF machine-bound key, obfuscated pepper, 7 vault tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 11 days ago (Jul 30, 2026) parent b48a3f4

Showing 2 changed files with +280 and −0

added Sources/ZyquoCloud/Services/SecureKeyStore.swift +174 −0
@@ -0,0 +1,174 @@
1 +//
2 +// SecureKeyStore.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +// Custom API-key vault — deliberately NOT the macOS Keychain.
9 +//
10 +// Design:
11 +// • Vault file ~/Library/Application Support/ZyquoCloud/vault.zq
12 +// 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 the
17 +// 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 in
22 +// plaintext, and redacted to their last 4 characters everywhere in the UI.
23 +//
24 +
25 +import CryptoKit
26 +import Foundation
27 +import IOKit
28 +import Security
29 +
30 +struct SecureKeyStore {
31 + enum VaultError: LocalizedError {
32 + case corrupted
33 + case machineIdentityUnavailable
34 +
35 + 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 + }
44 +
45 + private static let saltLength = 32
46 + private static let keyLength = 32
47 +
48 + let vaultURL: URL
49 + /// Overridable for tests; defaults to real machine identity.
50 + private let machineEntropy: () throws -> Data
51 +
52 + init(
53 + vaultURL: URL? = nil,
54 + machineEntropy: (() throws -> Data)? = nil
55 + ) {
56 + let root = PersistenceService.shared.rootDirectory
57 + self.vaultURL = vaultURL ?? root.appendingPathComponent("vault.zq")
58 + self.machineEntropy = machineEntropy ?? Self.defaultMachineEntropy
59 + }
60 +
61 + // MARK: - Public API
62 +
63 + /// 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.corrupted
76 + }
77 + }
78 +
79 + /// 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: true
90 + )
91 + try blob.write(to: vaultURL, options: [.atomic, .completeFileProtection])
92 + }
93 +
94 + func key(for provider: ProviderID) throws -> String? {
95 + try loadKeys()[provider.rawValue]
96 + }
97 +
98 + func setKey(_ apiKey: String, for provider: ProviderID) throws {
99 + var keys = try loadKeys()
100 + keys[provider.rawValue] = apiKey
101 + try saveKeys(keys)
102 + }
103 +
104 + func deleteKey(for provider: ProviderID) throws {
105 + var keys = try loadKeys()
106 + keys.removeValue(forKey: provider.rawValue)
107 + try saveKeys(keys)
108 + }
109 +
110 + /// "••••…abcd" display form. Never show more.
111 + static func redacted(_ apiKey: String) -> String {
112 + let suffix = apiKey.suffix(4)
113 + return "••••\(suffix)"
114 + }
115 +
116 + // MARK: - Key derivation
117 +
118 + 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.keyLength
126 + )
127 + }
128 +
129 + /// 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 entropy
135 + }
136 +
137 + /// 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, 0
146 + ) else { return nil }
147 + return property.takeRetainedValue() as? String
148 + }
149 +
150 + /// App pepper, assembled at runtime — the constants below are the pepper
151 + /// 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 + }
160 +
161 + 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 + }
167 +
168 + /// 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 +}
added Tests/ZyquoCloudTests/SecureKeyStoreTests.swift +106 −0
@@ -0,0 +1,106 @@
1 +//
2 +// SecureKeyStoreTests.swift
3 +// Zyquo Cloud
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Mail: contact@spboucher.ai
7 +//
8 +
9 +import Foundation
10 +import Testing
11 +@testable import ZyquoCloud
12 +
13 +@Suite struct SecureKeyStoreTests {
14 + private func temporaryVaultURL() -> URL {
15 + FileManager.default.temporaryDirectory
16 + .appendingPathComponent("zyquo-tests-\(UUID().uuidString)")
17 + .appendingPathComponent("vault.zq")
18 + }
19 +
20 + @Test func encryptDecryptRoundTrip() throws {
21 + let url = temporaryVaultURL()
22 + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
23 + let store = SecureKeyStore(vaultURL: url)
24 +
25 + let keys = [
26 + "openai": "sk-proj-test-1234",
27 + "anthropic": "sk-ant-test-5678",
28 + "mistral": "plainkey",
29 + ]
30 + try store.saveKeys(keys)
31 + let loaded = try store.loadKeys()
32 + #expect(loaded == keys)
33 +
34 + // Vault file must not contain any key material in plaintext.
35 + let raw = try Data(contentsOf: url)
36 + let rawString = String(decoding: raw, as: UTF8.self)
37 + #expect(!rawString.contains("sk-proj-test-1234"))
38 + #expect(!rawString.contains("sk-ant-test-5678"))
39 + #expect(!rawString.contains("openai"))
40 + }
41 +
42 + @Test func perProviderSetGetDelete() throws {
43 + let url = temporaryVaultURL()
44 + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
45 + let store = SecureKeyStore(vaultURL: url)
46 +
47 + try store.setKey("xai-abc", for: .xai)
48 + try store.setKey("pplx-def", for: .perplexity)
49 + #expect(try store.key(for: .xai) == "xai-abc")
50 + #expect(try store.key(for: .perplexity) == "pplx-def")
51 +
52 + try store.deleteKey(for: .xai)
53 + #expect(try store.key(for: .xai) == nil)
54 + #expect(try store.key(for: .perplexity) == "pplx-def")
55 + }
56 +
57 + @Test func emptyVaultLoadsEmpty() throws {
58 + let store = SecureKeyStore(vaultURL: temporaryVaultURL())
59 + #expect(try store.loadKeys().isEmpty)
60 + }
61 +
62 + @Test func tamperedVaultThrowsCorrupted() throws {
63 + let url = temporaryVaultURL()
64 + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
65 + let store = SecureKeyStore(vaultURL: url)
66 + try store.saveKeys(["openai": "sk-test"])
67 +
68 + var blob = try Data(contentsOf: url)
69 + blob[blob.count - 1] ^= 0xFF // flip a tag byte
70 + try blob.write(to: url)
71 +
72 + #expect(throws: SecureKeyStore.VaultError.self) {
73 + _ = try store.loadKeys()
74 + }
75 + }
76 +
77 + @Test func vaultIsMachineBound() throws {
78 + let url = temporaryVaultURL()
79 + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
80 +
81 + let machineA = SecureKeyStore(vaultURL: url, machineEntropy: { Data("machine-A".utf8) })
82 + try machineA.saveKeys(["openai": "sk-test"])
83 +
84 + let machineB = SecureKeyStore(vaultURL: url, machineEntropy: { Data("machine-B".utf8) })
85 + #expect(throws: SecureKeyStore.VaultError.self) {
86 + _ = try machineB.loadKeys()
87 + }
88 + }
89 +
90 + @Test func saltIsStableAcrossSaves() throws {
91 + let url = temporaryVaultURL()
92 + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
93 + let store = SecureKeyStore(vaultURL: url)
94 +
95 + try store.saveKeys(["a": "1"])
96 + let salt1 = try Data(contentsOf: url).prefix(32)
97 + try store.saveKeys(["a": "1", "b": "2"])
98 + let salt2 = try Data(contentsOf: url).prefix(32)
99 + #expect(salt1 == salt2)
100 + #expect(try store.loadKeys() == ["a": "1", "b": "2"])
101 + }
102 +
103 + @Test func redactionShowsOnlyLastFour() {
104 + #expect(SecureKeyStore.redacted("sk-proj-abcdefgh1234") == "••••1234")
105 + }
106 +}
107