forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDesktopKeychainStore.swift
More file actions
314 lines (283 loc) · 12.7 KB
/
Copy pathDesktopKeychainStore.swift
File metadata and controls
314 lines (283 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
import Foundation
import LocalAuthentication
import Security
/// Secret-store facade for desktop credentials.
///
/// Shipped bundles (`com.omi.computer-macos` and `com.omi.computer-macos.beta`,
/// `AppBuild.isProductionBundle`) persist through the login keychain. Every other
/// bundle (Omi Dev, named `omi-*` apps, ad-hoc / Apple Development local builds)
/// uses `DesktopDeveloperSecretStore` so rebuilds never call SecItem and never
/// prompt SecurityAgent.
///
/// Login-keychain constraints (production only; see #9167 / keychain ACL prompt fix):
/// - Never opt into the data-protection keychain (`kSecUseDataProtectionKeychain`) — this
/// non-sandboxed Developer ID app has no `keychain-access-groups` entitlement.
/// - Never present the macOS keychain password dialog. Reads/writes that would require UI
/// fail closed (`nil` / `false`).
/// - Scope service names by signing Team ID **and** bundle id so:
/// - Apple Development / named-bundle builds cannot poison notarized Beta/Prod
/// - Local contributors' Omi Dev / `omi-*` / ad-hoc rebuilds cannot poison each other
/// (path/signature ACL mismatches between same-team apps)
/// - Do **not** query the pre-scoping legacy service names from app code. Those items may
/// carry a foreign-team ACL; even with `LAContext.interactionNotAllowed`, SecItem can
/// still surface the login-keychain password sheet (`errSecUserCanceled` / -128). Leave
/// orphans alone — UserDefaults migration covers auth continuity for older installs.
enum DesktopKeychainStore {
enum Backend: Equatable {
case keychain
case developerFile
}
struct KeychainOperations {
var readString: (_ service: String, _ account: String) -> ReadResult
var setString: (_ value: String, _ service: String, _ account: String) -> Bool
var delete: (_ service: String, _ account: String) -> Void
}
/// Pre-scoping service names. Kept as constants for dump/seed scripts and docs only —
/// app runtime must not SecItem-query these (see file header).
static let legacyAuthTokenService = "com.omi.desktop.firebase-rest-session"
static let legacyLocalAgentTokenService = "com.omi.desktop.local-agent-api"
static let legacyClientDeviceService = "com.omi.client-device-id"
/// Test seam: force a backend instead of `AppBuild.isProductionBundle`.
nonisolated(unsafe) static var backendOverride: Backend?
/// Test seam: spy or stub the production keychain operations.
nonisolated(unsafe) static var keychainOperationsOverride: KeychainOperations?
/// Test seam: replace the developer file store (typically a temp-directory instance).
nonisolated(unsafe) static var developerSecretStoreOverride: DesktopDeveloperSecretStore?
static func resetTestHooks() {
backendOverride = nil
keychainOperationsOverride = nil
developerSecretStoreOverride = nil
_cachedBackend = nil
}
static func backend(forBundleIdentifier identifier: String) -> Backend {
AppBuild.productionFamilyBundleIdentifiers.contains(identifier) ? .keychain : .developerFile
}
static var backend: Backend {
if let backendOverride {
return backendOverride
}
if let _cachedBackend {
return _cachedBackend
}
let resolved: Backend = AppBuild.isProductionBundle ? .keychain : .developerFile
_cachedBackend = resolved
return resolved
}
private nonisolated(unsafe) static var _cachedBackend: Backend?
private static var developerSecretStore: DesktopDeveloperSecretStore {
developerSecretStoreOverride ?? .shared
}
/// Signing Team ID of the running binary (e.g. `9536L8KLMP` for Developer ID,
/// `JVMXE5G542` for a personal Apple Development cert). Falls back to an ad-hoc
/// bundle-scoped token when codesign info has no Team ID.
static var signingTeamID: String {
if let cached = _cachedSigningTeamID {
return cached
}
let resolved = resolveSigningTeamID()
_cachedSigningTeamID = resolved
return resolved
}
private nonisolated(unsafe) static var _cachedSigningTeamID: String?
/// Team + bundle scoped service name.
///
/// Format: `<base>.v2.team.<TeamID>.bundle.<bundleID>`
///
/// Beta and stable share `com.omi.computer-macos` + Developer ID team, so they keep one
/// auth item. Every local contributor bundle (`com.omi.desktop-dev`, `com.omi.omi-*`,
/// ad-hoc) gets its own item — dump/seed scripts write into the *target* bundle's
/// scoped service explicitly.
static func scopedService(
_ base: String,
teamID: String = signingTeamID,
bundleID: String = Bundle.main.bundleIdentifier ?? "unknown.bundle"
) -> String {
"\(base).v2.team.\(teamID).bundle.\(bundleID)"
}
private static func baseQuery(service: String, account: String) -> [String: Any] {
// Use the file-based (login) keychain, NOT the iOS-style data-protection keychain.
// Opting into the data-protection keychain requires a `keychain-access-groups` entitlement
// this non-sandboxed Developer ID app does not have (see Omi-Release.entitlements:
// app-sandbox=false, no keychain-access-groups). On the signed/notarized build that made
// every SecItem write fail with errSecMissingEntitlement (-34018), so token storage failed
// ("Could not securely store sign-in tokens") and sign-in was blocked. The default
// file-based keychain works for a signed non-sandboxed app with no extra entitlement and
// still keeps tokens out of UserDefaults.
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
/// Attach silent-auth constraints so SecItem prefers failing over showing UI.
/// Note: this does **not** reliably suppress file-based keychain ACL password sheets for
/// foreign TrustedApplication items — callers must not query those legacy services.
private static func applySilentAuth(_ query: inout [String: Any]) {
let context = LAContext()
context.interactionNotAllowed = true
query[kSecUseAuthenticationContext as String] = context
}
private static func isMissing(_ status: OSStatus) -> Bool {
status == errSecItemNotFound
}
/// Statuses that mean "cannot access without UI / wrong ACL / user would be prompted".
private static func isAuthUnavailable(_ status: OSStatus) -> Bool {
status == errSecInteractionNotAllowed
|| status == errSecAuthFailed
|| status == errSecUserCanceled
|| status == errSecMissingEntitlement
}
enum ReadResult: Equatable {
case found(String)
case missing
case unavailable(OSStatus)
}
static func readString(service: String, account: String) -> ReadResult {
switch backend {
case .developerFile:
if let value = developerSecretStore.readString(service: service, account: account) {
return .found(value)
}
return .missing
case .keychain:
return keychainOperationsOverride?.readString(service, account)
?? keychainReadString(service: service, account: account)
}
}
static func string(service: String, account: String) -> String? {
if case .found(let value) = readString(service: service, account: account) {
return value
}
return nil
}
@discardableResult
static func setString(_ value: String, service: String, account: String) -> Bool {
switch backend {
case .developerFile:
return developerSecretStore.setString(value, service: service, account: account)
case .keychain:
return keychainOperationsOverride?.setString(value, service, account)
?? keychainSetString(value, service: service, account: account)
}
}
static func delete(service: String, account: String) {
switch backend {
case .developerFile:
developerSecretStore.delete(service: service, account: account)
case .keychain:
if let keychainOperationsOverride {
keychainOperationsOverride.delete(service, account)
} else {
keychainDelete(service: service, account: account)
}
}
}
private static func keychainReadString(service: String, account: String) -> ReadResult {
var query = baseQuery(service: service, account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
applySilentAuth(&query)
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess {
guard let data = item as? Data, let value = String(data: data, encoding: .utf8), !value.isEmpty else {
return .missing
}
return .found(value)
}
if isMissing(status) {
return .missing
}
if isAuthUnavailable(status) {
log("DesktopKeychainStore: silent read unavailable for \(service)/\(account) (status \(status))")
return .unavailable(status)
}
log("DesktopKeychainStore: read failed for \(service)/\(account) (status \(status))")
return .unavailable(status)
}
private static func keychainSetString(_ value: String, service: String, account: String) -> Bool {
let data = Data(value.utf8)
var query = baseQuery(service: service, account: account)
applySilentAuth(&query)
let attributes: [String: Any] = [
kSecValueData as String: data,
// Advisory on the file-based keychain (it's a data-protection-keychain attribute, so it's
// accepted but not cryptographically enforced here); kept for intent + parity with iOS.
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecSuccess {
return true
}
if !isMissing(updateStatus) {
if isAuthUnavailable(updateStatus) {
// Never delete an existing credential merely because a write is temporarily
// unavailable. Delete-then-add can turn a recoverable locked-Keychain or ACL
// condition into permanent session loss if the subsequent add also fails.
log(
"DesktopKeychainStore: update unavailable; preserving existing item for \(service)/\(account) (status \(updateStatus))"
)
return false
} else {
log("DesktopKeychainStore: update failed for \(service)/\(account) (status \(updateStatus))")
return false
}
}
var addQuery = baseQuery(service: service, account: account)
applySilentAuth(&addQuery)
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess {
return true
}
if addStatus == errSecDuplicateItem {
let retry = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if retry == errSecSuccess {
return true
}
log("DesktopKeychainStore: add/update race failed for \(service)/\(account) (status \(retry))")
return false
}
log("DesktopKeychainStore: add failed for \(service)/\(account) (status \(addStatus))")
return false
}
private static func keychainDelete(service: String, account: String) {
var query = baseQuery(service: service, account: account)
applySilentAuth(&query)
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && !isMissing(status) {
// Wrong-team / ACL-blocked deletes must stay silent — never escalate to a prompt.
log("DesktopKeychainStore: delete failed for \(service)/\(account) (status \(status))")
}
}
private static func resolveSigningTeamID() -> String {
var code: SecCode?
var status = SecCodeCopySelf([], &code)
guard status == errSecSuccess, let code else {
log("DesktopKeychainStore: SecCodeCopySelf failed (\(status)); using unknown team scope")
return "unknown"
}
var staticCode: SecStaticCode?
status = SecCodeCopyStaticCode(code, [], &staticCode)
guard status == errSecSuccess, let staticCode else {
log("DesktopKeychainStore: SecCodeCopyStaticCode failed (\(status)); using unknown team scope")
return "unknown"
}
var info: CFDictionary?
status = SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &info)
guard status == errSecSuccess, let info = info as? [String: Any] else {
log("DesktopKeychainStore: SecCodeCopySigningInformation failed (\(status)); using unknown team scope")
return "unknown"
}
if let team = info[kSecCodeInfoTeamIdentifier as String] as? String, !team.isEmpty {
return team
}
// Ad-hoc / unsigned local builds have no Team ID. Scope by bundle id so they still
// cannot collide with Developer ID / Apple Development items.
if let bundleID = Bundle.main.bundleIdentifier, !bundleID.isEmpty {
return "adhoc.\(bundleID)"
}
return "unknown"
}
}