|
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 |
+} |