Skip to content

Commit 7900ff4

Browse files
yetvalsteipete
andauthored
fix(macos): preserve device identity storage (#101105)
* fix(macos): preserve device identity storage Avoid selecting App Group identity storage when the running app lacks the matching application-groups entitlement, so unentitled macOS builds fall back to the legacy writable identity directory. Existing unrecognized identity files are now preserved instead of being replaced by a newly generated identity, matching the no-overwrite behavior already used for recognized invalid identity data. * fix(macos): repair identity CI coverage Limit the App Group entitlement probe to macOS builds so iOS keeps its existing App Group path behavior. Update the state-dir identity regression to assert the new no-overwrite contract for invalid identity-shaped files. * fix(macos): migrate existing app group identity on legacy fallback Unentitled macOS builds now migrate a readable App Group device identity into the selected legacy store before falling back, so an upgrade preserves the existing device id instead of minting a new one. * fix(macos): migrate device auth store with identity on app group fallback The app group to legacy identity migration now carries the sibling device-auth store file, so an unentitled build keeps its stored device tokens (keyed by the migrated deviceId) instead of re-pairing. The process-immutable entitlement check is resolved once per process rather than creating a SecTask on every state-dir lookup, and the migration source API returns an identity+auth pair struct. --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 4112e3a commit 7900ff4

5 files changed

Lines changed: 355 additions & 18 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import CryptoKit
22
import Foundation
3+
#if canImport(Security)
4+
import Security
5+
#endif
36

47
public enum GatewayDeviceIdentityProfile: String, Sendable {
58
case primary
@@ -46,24 +49,32 @@ public struct DeviceIdentity: Codable, Sendable {
4649
enum DeviceIdentityPaths {
4750
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
4851

52+
/// Entitlements are baked into the code signature, so resolve the gate once per process.
53+
/// Every identity load and DeviceAuthStore read/write resolves the state dir through here;
54+
/// re-creating a SecTask each time is wasted work for a process-immutable fact.
55+
private static let appGroupStateDirAvailable =
56+
DeviceIdentityPaths.hasAppGroupEntitlement(OpenClawAppGroup.identifier)
57+
4958
static func stateDirURL() -> URL {
5059
self.stateDirURL(
5160
overrideURL: self.stateDirOverrideURL(),
5261
legacyStateDirURL: self.legacyStateDirURL(),
5362
appGroupStateDirURL: self.appGroupStateDirURL(),
63+
appGroupStateDirAvailable: self.appGroupStateDirAvailable,
5464
temporaryDirectory: FileManager.default.temporaryDirectory)
5565
}
5666

5767
static func stateDirURL(
5868
overrideURL: URL?,
5969
legacyStateDirURL: URL?,
6070
appGroupStateDirURL: URL?,
71+
appGroupStateDirAvailable: Bool = true,
6172
temporaryDirectory: URL) -> URL
6273
{
6374
if let overrideURL {
6475
return overrideURL
6576
}
66-
if let appGroupStateDirURL {
77+
if appGroupStateDirAvailable, let appGroupStateDirURL {
6778
return appGroupStateDirURL
6879
}
6980
if let legacyStateDirURL {
@@ -91,6 +102,30 @@ enum DeviceIdentityPaths {
91102
return nil
92103
}
93104

105+
private static func hasAppGroupEntitlement(_ identifier: String) -> Bool {
106+
// macOS resolves containerURL(forSecurityApplicationGroupIdentifier:) even without the
107+
// App Groups entitlement, but macOS 15+ gates actual access behind a user consent prompt.
108+
// Unentitled builds (the shipped mac app) must not depend on that container. iOS requires
109+
// the entitlement for containerURL to resolve at all, so the gate is macOS-only.
110+
#if os(macOS) && canImport(Security)
111+
guard
112+
let task = SecTaskCreateFromSelf(nil),
113+
let value = SecTaskCopyValueForEntitlement(
114+
task,
115+
"com.apple.security.application-groups" as CFString,
116+
nil)
117+
else {
118+
return false
119+
}
120+
guard let groups = value as? [String] else {
121+
return false
122+
}
123+
return groups.contains(identifier)
124+
#else
125+
return true
126+
#endif
127+
}
128+
94129
private static func appGroupStateDirURL() -> URL? {
95130
guard
96131
let containerURL = FileManager.default
@@ -100,6 +135,40 @@ enum DeviceIdentityPaths {
100135
}
101136
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
102137
}
138+
139+
/// Files a one-time fallback migration may carry from the App Group container into the
140+
/// selected store. Stored device tokens are keyed by deviceId, so the identity file is
141+
/// only useful together with its auth sibling; migrating one without the other forces
142+
/// an unnecessary re-pair even though the deviceId survived.
143+
struct AppGroupMigrationSource {
144+
let identityURL: URL
145+
let authURL: URL
146+
}
147+
148+
static func appGroupMigrationSource(
149+
profile: GatewayDeviceIdentityProfile) -> AppGroupMigrationSource?
150+
{
151+
self.appGroupMigrationSource(
152+
appGroupStateDirURL: self.appGroupStateDirURL(),
153+
appGroupStateDirAvailable: self.appGroupStateDirAvailable,
154+
profile: profile)
155+
}
156+
157+
/// Non-nil only for unentitled builds whose store selection fell back to legacy storage;
158+
/// entitled builds keep using the App Group container and must never migrate out of it.
159+
static func appGroupMigrationSource(
160+
appGroupStateDirURL: URL?,
161+
appGroupStateDirAvailable: Bool,
162+
profile: GatewayDeviceIdentityProfile) -> AppGroupMigrationSource?
163+
{
164+
guard !appGroupStateDirAvailable, let appGroupStateDirURL else {
165+
return nil
166+
}
167+
let identityDirURL = appGroupStateDirURL.appendingPathComponent("identity", isDirectory: true)
168+
return AppGroupMigrationSource(
169+
identityURL: identityDirURL.appendingPathComponent(profile.identityFileName, isDirectory: false),
170+
authURL: identityDirURL.appendingPathComponent(profile.authFileName, isDirectory: false))
171+
}
103172
}
104173

105174
public enum DeviceIdentityStore {
@@ -117,25 +186,75 @@ public enum DeviceIdentityStore {
117186
}
118187

119188
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
120-
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
189+
self.loadOrCreate(
190+
fileURL: self.fileURL(profile: profile),
191+
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(profile: profile))
121192
}
122193

123-
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
194+
static func loadOrCreate(
195+
fileURL url: URL,
196+
migrationSource: DeviceIdentityPaths.AppGroupMigrationSource? = nil) -> DeviceIdentity
197+
{
124198
if let data = try? Data(contentsOf: url) {
125199
switch self.decodeStoredIdentity(data) {
126200
case let .identity(decoded):
127201
return decoded
128-
case .recognizedInvalid:
202+
case .recognizedInvalid, .unknown:
203+
// Existing bytes may hold a newer schema or recoverable key material; never
204+
// overwrite them. Callers run with a transient identity instead.
129205
return self.generate()
130-
case .unknown:
131-
break
132206
}
133207
}
208+
if FileManager.default.fileExists(atPath: url.path) {
209+
return self.generate()
210+
}
211+
if let migrated = self.migratedIdentity(from: migrationSource, to: url) {
212+
return migrated
213+
}
134214
let identity = self.generate()
135215
self.save(identity, to: url)
136216
return identity
137217
}
138218

219+
/// One-time upgrade path for builds that lost App Group storage: it runs only while the
220+
/// selected store has no identity file, so steady state never re-reads the old container.
221+
private static func migratedIdentity(
222+
from source: DeviceIdentityPaths.AppGroupMigrationSource?,
223+
to destinationURL: URL) -> DeviceIdentity?
224+
{
225+
guard
226+
let source,
227+
let data = try? Data(contentsOf: source.identityURL),
228+
case let .identity(identity) = self.decodeStoredIdentity(data)
229+
else {
230+
return nil
231+
}
232+
self.save(identity, to: destinationURL)
233+
// Stored device tokens only load when their store's deviceId matches (DeviceAuthStore),
234+
// so they must move together with the identity or the install re-pairs for no reason.
235+
// A mismatched copy is inert behind that same check; no validation needed here.
236+
self.copyAuthStoreFile(
237+
from: source.authURL,
238+
toDirectory: destinationURL.deletingLastPathComponent())
239+
return identity
240+
}
241+
242+
private static func copyAuthStoreFile(from sourceURL: URL, toDirectory directoryURL: URL) {
243+
let fileManager = FileManager.default
244+
let destinationURL = directoryURL
245+
.appendingPathComponent(sourceURL.lastPathComponent, isDirectory: false)
246+
guard
247+
!fileManager.fileExists(atPath: destinationURL.path),
248+
fileManager.fileExists(atPath: sourceURL.path)
249+
else {
250+
return
251+
}
252+
try? fileManager.copyItem(at: sourceURL, to: destinationURL)
253+
try? fileManager.setAttributes(
254+
[.posixPermissions: 0o600],
255+
ofItemAtPath: destinationURL.path)
256+
}
257+
139258
private enum DecodeResult {
140259
case identity(DeviceIdentity)
141260
case recognizedInvalid

apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,24 @@ struct DeviceIdentityStoreTests {
196196
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
197197
}
198198

199+
@Test
200+
func `legacy app support storage wins when app group storage is not available`() {
201+
let tempDir = FileManager.default.temporaryDirectory
202+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
203+
defer { try? FileManager.default.removeItem(at: tempDir) }
204+
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
205+
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
206+
207+
let selected = DeviceIdentityPaths.stateDirURL(
208+
overrideURL: nil,
209+
legacyStateDirURL: legacyURL,
210+
appGroupStateDirURL: sharedURL,
211+
appGroupStateDirAvailable: false,
212+
temporaryDirectory: tempDir)
213+
214+
#expect(selected == legacyURL)
215+
}
216+
199217
@Test(.stateDirectoryIsolated)
200218
func `secondary profiles use separate identity and auth files`() throws {
201219
let tempDir = FileManager.default.temporaryDirectory
@@ -326,6 +344,174 @@ struct DeviceIdentityStoreTests {
326344
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
327345
}
328346

347+
@Test
348+
func `does not overwrite an existing unrecognized identity file`() throws {
349+
let tempDir = FileManager.default.temporaryDirectory
350+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
351+
let identityURL = tempDir
352+
.appendingPathComponent("identity", isDirectory: true)
353+
.appendingPathComponent("device.json", isDirectory: false)
354+
defer { try? FileManager.default.removeItem(at: tempDir) }
355+
try FileManager.default.createDirectory(
356+
at: identityURL.deletingLastPathComponent(),
357+
withIntermediateDirectories: true)
358+
let stored = """
359+
{
360+
"schema": "future-openclaw-device-identity",
361+
"stableDeviceId": "app-group-device-id"
362+
}
363+
"""
364+
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
365+
let before = try String(contentsOf: identityURL, encoding: .utf8)
366+
367+
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
368+
369+
#expect(identity.deviceId != "app-group-device-id")
370+
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
371+
}
372+
373+
@Test
374+
func `migrates an existing app group identity and auth store when falling back to legacy storage`() throws {
375+
let tempDir = FileManager.default.temporaryDirectory
376+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
377+
defer { try? FileManager.default.removeItem(at: tempDir) }
378+
let appGroupURL = tempDir.appendingPathComponent("shared", isDirectory: true)
379+
let appGroupIdentityURL = appGroupURL
380+
.appendingPathComponent("identity", isDirectory: true)
381+
.appendingPathComponent("device.json", isDirectory: false)
382+
let appGroupAuthURL = appGroupIdentityURL
383+
.deletingLastPathComponent()
384+
.appendingPathComponent("device-auth.json", isDirectory: false)
385+
let legacyIdentityURL = tempDir
386+
.appendingPathComponent("legacy", isDirectory: true)
387+
.appendingPathComponent("identity", isDirectory: true)
388+
.appendingPathComponent("device.json", isDirectory: false)
389+
let legacyAuthURL = legacyIdentityURL
390+
.deletingLastPathComponent()
391+
.appendingPathComponent("device-auth.json", isDirectory: false)
392+
try FileManager.default.createDirectory(
393+
at: appGroupIdentityURL.deletingLastPathComponent(),
394+
withIntermediateDirectories: true)
395+
let stored = try Self.identityJSON(
396+
publicKeyPem: Self.pem(
397+
label: "PUBLIC KEY",
398+
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
399+
privateKeyPem: Self.pem(
400+
label: "PRIVATE KEY",
401+
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
402+
try stored.write(to: appGroupIdentityURL, atomically: true, encoding: .utf8)
403+
let storedAuth = #"{"version":1,"deviceId":"app-group-device-id","tokens":{}}"#
404+
try storedAuth.write(to: appGroupAuthURL, atomically: true, encoding: .utf8)
405+
let appGroupBefore = try String(contentsOf: appGroupIdentityURL, encoding: .utf8)
406+
407+
let migrationSource = DeviceIdentityPaths.appGroupMigrationSource(
408+
appGroupStateDirURL: appGroupURL,
409+
appGroupStateDirAvailable: false,
410+
profile: .primary)
411+
let identity = DeviceIdentityStore.loadOrCreate(
412+
fileURL: legacyIdentityURL,
413+
migrationSource: migrationSource)
414+
415+
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
416+
#expect(FileManager.default.fileExists(atPath: legacyIdentityURL.path))
417+
let reloaded = DeviceIdentityStore.loadOrCreate(fileURL: legacyIdentityURL)
418+
#expect(reloaded.deviceId == identity.deviceId)
419+
#expect(try String(contentsOf: appGroupIdentityURL, encoding: .utf8) == appGroupBefore)
420+
#expect(try String(contentsOf: legacyAuthURL, encoding: .utf8) == storedAuth)
421+
#expect(try String(contentsOf: appGroupAuthURL, encoding: .utf8) == storedAuth)
422+
}
423+
424+
@Test
425+
func `does not clobber an existing auth store when migrating an app group identity`() throws {
426+
let tempDir = FileManager.default.temporaryDirectory
427+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
428+
defer { try? FileManager.default.removeItem(at: tempDir) }
429+
let appGroupIdentityDirURL = tempDir
430+
.appendingPathComponent("shared", isDirectory: true)
431+
.appendingPathComponent("identity", isDirectory: true)
432+
let legacyIdentityDirURL = tempDir
433+
.appendingPathComponent("legacy", isDirectory: true)
434+
.appendingPathComponent("identity", isDirectory: true)
435+
try FileManager.default.createDirectory(at: appGroupIdentityDirURL, withIntermediateDirectories: true)
436+
try FileManager.default.createDirectory(at: legacyIdentityDirURL, withIntermediateDirectories: true)
437+
let stored = try Self.identityJSON(
438+
publicKeyPem: Self.pem(
439+
label: "PUBLIC KEY",
440+
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
441+
privateKeyPem: Self.pem(
442+
label: "PRIVATE KEY",
443+
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
444+
try stored.write(
445+
to: appGroupIdentityDirURL.appendingPathComponent("device.json", isDirectory: false),
446+
atomically: true,
447+
encoding: .utf8)
448+
try #"{"version":1,"deviceId":"app-group-device-id","tokens":{}}"#.write(
449+
to: appGroupIdentityDirURL.appendingPathComponent("device-auth.json", isDirectory: false),
450+
atomically: true,
451+
encoding: .utf8)
452+
let legacyAuthURL = legacyIdentityDirURL.appendingPathComponent("device-auth.json", isDirectory: false)
453+
let existingLegacyAuth = #"{"version":1,"deviceId":"legacy-device-id","tokens":{}}"#
454+
try existingLegacyAuth.write(to: legacyAuthURL, atomically: true, encoding: .utf8)
455+
456+
let identity = DeviceIdentityStore.loadOrCreate(
457+
fileURL: legacyIdentityDirURL.appendingPathComponent("device.json", isDirectory: false),
458+
migrationSource: DeviceIdentityPaths.appGroupMigrationSource(
459+
appGroupStateDirURL: tempDir.appendingPathComponent("shared", isDirectory: true),
460+
appGroupStateDirAvailable: false,
461+
profile: .primary))
462+
463+
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
464+
#expect(try String(contentsOf: legacyAuthURL, encoding: .utf8) == existingLegacyAuth)
465+
}
466+
467+
@Test
468+
func `keeps an existing legacy identity instead of migrating the app group copy`() throws {
469+
let tempDir = FileManager.default.temporaryDirectory
470+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
471+
defer { try? FileManager.default.removeItem(at: tempDir) }
472+
let appGroupURL = tempDir.appendingPathComponent("shared", isDirectory: true)
473+
let appGroupIdentityURL = appGroupURL
474+
.appendingPathComponent("identity", isDirectory: true)
475+
.appendingPathComponent("device.json", isDirectory: false)
476+
let legacyIdentityURL = tempDir
477+
.appendingPathComponent("legacy", isDirectory: true)
478+
.appendingPathComponent("identity", isDirectory: true)
479+
.appendingPathComponent("device.json", isDirectory: false)
480+
try FileManager.default.createDirectory(
481+
at: appGroupIdentityURL.deletingLastPathComponent(),
482+
withIntermediateDirectories: true)
483+
let stored = try Self.identityJSON(
484+
publicKeyPem: Self.pem(
485+
label: "PUBLIC KEY",
486+
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
487+
privateKeyPem: Self.pem(
488+
label: "PRIVATE KEY",
489+
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
490+
try stored.write(to: appGroupIdentityURL, atomically: true, encoding: .utf8)
491+
492+
let existingLegacy = DeviceIdentityStore.loadOrCreate(fileURL: legacyIdentityURL)
493+
let migrationSource = DeviceIdentityPaths.appGroupMigrationSource(
494+
appGroupStateDirURL: appGroupURL,
495+
appGroupStateDirAvailable: false,
496+
profile: .primary)
497+
let identity = DeviceIdentityStore.loadOrCreate(
498+
fileURL: legacyIdentityURL,
499+
migrationSource: migrationSource)
500+
501+
#expect(identity.deviceId == existingLegacy.deviceId)
502+
#expect(identity.deviceId != "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
503+
}
504+
505+
@Test
506+
func `provides no app group migration source when the entitlement is present`() {
507+
let appGroupURL = FileManager.default.temporaryDirectory
508+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
509+
#expect(DeviceIdentityPaths.appGroupMigrationSource(
510+
appGroupStateDirURL: appGroupURL,
511+
appGroupStateDirAvailable: true,
512+
profile: .primary) == nil)
513+
}
514+
329515
private static func base64UrlDecode(_ value: String) -> Data? {
330516
let normalized = value
331517
.replacingOccurrences(of: "-", with: "+")

0 commit comments

Comments
 (0)