forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathClientDeviceService.swift
More file actions
220 lines (204 loc) · 8.15 KB
/
Copy pathClientDeviceService.swift
File metadata and controls
220 lines (204 loc) · 8.15 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
import CryptoKit
import Foundation
import LocalAuthentication
import Security
enum ClientDeviceKeychainReadResult {
case found(String)
case missing
case unavailable(OSStatus)
}
/// Stable per-installation device identity for capture provenance (mirrors Flutter `deviceIdHash`).
final class ClientDeviceService {
nonisolated(unsafe) static let shared = ClientDeviceService()
private let keychainAccount = "install-uuid"
private let devInstallIdDefaultsKey = "dev-client-device-install-uuid"
private let installIdMirrorDefaultsKey = "client-device-install-uuid-mirror"
private let bundleIdentifier: String?
private let userDefaults: UserDefaults
private let keychainReader: (() -> ClientDeviceKeychainReadResult)?
private let keychainWriter: ((String) -> Void)?
private let cacheLock = NSLock()
private var cachedInstallId: String?
/// Team+bundle scoped service for this process. Never the shared legacy
/// `com.omi.client-device-id` name — querying that from a binary not on its ACL
/// is what caused keychain password prompt spam (#8799).
private var keychainService: String {
DesktopKeychainStore.scopedService(
DesktopKeychainStore.legacyClientDeviceService,
bundleID: bundleIdentifier ?? Bundle.main.bundleIdentifier ?? "unknown.bundle"
)
}
init(
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
userDefaults: UserDefaults = .standard,
keychainReader: (() -> ClientDeviceKeychainReadResult)? = nil,
keychainWriter: ((String) -> Void)? = nil
) {
self.bundleIdentifier = bundleIdentifier
self.userDefaults = userDefaults
self.keychainReader = keychainReader
self.keychainWriter = keychainWriter
}
var deviceIdHash: String {
let installId = resolveInstallId()
let digest = SHA256.hash(data: Data(installId.utf8))
return digest.map { String(format: "%02x", $0) }.joined().prefix(8).description
}
/// The durable, per-installation random identity used as the local key for
/// JIT's opaque correlation identifiers. This is deliberately not derived
/// from the machine name, account, or captured content. It is persisted in
/// the scoped Keychain for production builds and in bundle-scoped defaults
/// for development builds, matching the existing device identity lifetime.
var installationIdentity: String {
resolveInstallId()
}
/// Contract: `{platform}_{hash}` — same shape as backend FCM `device_key`.
var clientDeviceId: String {
"macos_\(deviceIdHash)"
}
func deviceProvenanceLabel(for memory: ServerMemory) -> String? {
let localId = clientDeviceId
if memory.primaryCaptureDevice == localId {
return "This Mac"
}
if let device = memory.primaryCaptureDevice, !device.isEmpty {
let platform = device.split(separator: "_").first.map(String.init) ?? device
switch platform {
case "macos": return "Mac"
case "ios": return "iPhone"
case "android": return "Android"
default: return platform.capitalized
}
}
return nil
}
func memoryMatchesThisDevice(_ memory: ServerMemory) -> Bool {
let localId = clientDeviceId
if memory.primaryCaptureDevice == localId {
return true
}
return memory.captureDeviceIds.contains(localId)
}
private func resolveInstallId() -> String {
cacheLock.lock()
defer { cacheLock.unlock() }
if let cached = cachedInstallId {
return cached
}
let resolved = loadOrCreateInstallId()
cachedInstallId = resolved
return resolved
}
private func loadOrCreateInstallId() -> String {
// All non-production bundles (Omi Dev + named omi-*) stay out of Keychain
// entirely — UserDefaults is enough for throwaway local identity and never
// prompts. Production Beta/Prod use the team+bundle scoped Keychain item.
if usesUserDefaultsInstallId {
return loadOrCreateDevInstallId()
}
switch keychainReader?() ?? readKeychainInstallId() {
case .found(let existing):
userDefaults.set(existing, forKey: installIdMirrorDefaultsKey)
return existing
case .missing:
// v0.12.64 moved production builds to a team+bundle scoped Keychain
// service. Existing installs have their prior stable value in this
// mirror, so migrate it instead of changing the provenance identity.
if let mirror = userDefaults.string(forKey: installIdMirrorDefaultsKey), !mirror.isEmpty {
saveKeychainInstallId(mirror)
return mirror
}
let fresh = UUID().uuidString
saveKeychainInstallId(fresh)
userDefaults.set(fresh, forKey: installIdMirrorDefaultsKey)
return fresh
case .unavailable(let status):
// Denied prompt or transient keychain failure. Never rotate the item here —
// and never fall through to the legacy unscoped service (that prompts).
log("ClientDeviceService: keychain read unavailable (status \(status)); using mirror fallback")
if let mirror = userDefaults.string(forKey: installIdMirrorDefaultsKey), !mirror.isEmpty {
return mirror
}
let fallback = UUID().uuidString
userDefaults.set(fallback, forKey: installIdMirrorDefaultsKey)
return fallback
}
}
private var usesUserDefaultsInstallId: Bool {
guard let bundleIdentifier else { return false }
// Any non-production com.omi.* bundle (desktop-dev + omi-*) — avoid Keychain.
// Production-family bundles (stable + Omi Beta) keep the durable Keychain id.
return bundleIdentifier.hasPrefix("com.omi.")
&& !AppBuild.productionFamilyBundleIdentifiers.contains(bundleIdentifier)
}
private func loadOrCreateDevInstallId() -> String {
if let existing = userDefaults.string(forKey: devInstallIdDefaultsKey), !existing.isEmpty {
return existing
}
let fresh = UUID().uuidString
userDefaults.set(fresh, forKey: devInstallIdDefaultsKey)
return fresh
}
private func readKeychainInstallId() -> ClientDeviceKeychainReadResult {
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data, let value = String(data: data, encoding: .utf8), !value.isEmpty else {
return .missing
}
return .found(value)
case errSecItemNotFound:
return .missing
default:
// errSecAuthFailed / errSecUserCanceled / errSecInteractionNotAllowed etc.
return .unavailable(status)
}
}
private func saveKeychainInstallId(_ value: String) {
if let keychainWriter {
keychainWriter(value)
return
}
let data = Data(value.utf8)
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecUseAuthenticationContext as String: context,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecSuccess {
return
}
if updateStatus != errSecItemNotFound {
// Do not SecItemDelete+Add on auth failure — that can prompt. Fail closed;
// the mirror fallback in loadOrCreateInstallId covers continuity.
log("ClientDeviceService: keychain update unavailable (status \(updateStatus))")
return
}
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus != errSecSuccess {
log("ClientDeviceService: keychain add unavailable (status \(addStatus))")
}
}
}