diff --git a/watch/README.md b/watch/README.md index 33ec6d5..e2d7699 100644 --- a/watch/README.md +++ b/watch/README.md @@ -66,10 +66,12 @@ there is one (`wristcode://thread//`). WidgetKit refreshes it the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and after a notification action. -The app and the extension share the App Group `group.org.gbrown.wristcode`. The device token and -gateway URL live in that group's Keychain access group, and the demo flag and last summary in its -defaults. Builds before the complication kept the token in the app's own Keychain group; -`DeviceStore.migrateToSharedGroup()` moves it on launch, so a paired watch stays paired. +The app and the extension share the Keychain access group `.org.gbrown.wristcode.shared`, which +holds the device token, gateway URL, demo flag and last summary. It is not an App Group because +automatic signing with an App Store Connect API key can register bundle IDs and profiles but cannot +create or assign App Groups; a Keychain group needs no portal setup. Builds before the complication +kept the token in the app's own Keychain group; `DeviceStore.migrateToSharedGroup()` moves it on +launch, so a paired watch stays paired. To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode. @@ -83,7 +85,7 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, | `API/Models.swift` | Codable mirror of the contract | | `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs | | `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group | -| `Shared/SharedState.swift` | Demo flag and last summary in the App Group, for the complication | +| `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `Views/` | Pair, Feed, Thread, New thread, and the mock B theme | | `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions | diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index fff5e79..c57599a 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -1,6 +1,6 @@ // App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also -// keeps the complication current: it shares the mode and latest counts through the App Group and -// reloads the complication's timeline when they change. +// keeps the complication current: it shares the mode and latest counts through the shared Keychain +// group and reloads the complication's timeline when they change. import Foundation import Observation diff --git a/watch/WristCode/Auth/DeviceStore.swift b/watch/WristCode/Auth/DeviceStore.swift index 3f748d1..2a18a7f 100644 --- a/watch/WristCode/Auth/DeviceStore.swift +++ b/watch/WristCode/Auth/DeviceStore.swift @@ -1,9 +1,9 @@ // The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so // a notification action handled in the background can still call the gateway. // -// The items live in the App Group's Keychain access group, so the complication can read them too. -// Builds before the complication kept them in the app's own access group; `migrateToSharedGroup()` -// moves them on launch, so a paired watch stays paired. +// The items live in a Keychain access group the app and the complication share, so the complication +// can read them too. Builds before the complication kept them in the app's own access group; +// `migrateToSharedGroup()` moves them on launch, so a paired watch stays paired. import Foundation import Security @@ -16,8 +16,9 @@ struct Credentials: Equatable, Sendable { enum DeviceStore { static let defaultGatewayURL = "https://wristcode.gbrown.org" - /// The App Group the app and the complication share. It doubles as the Keychain access group. - static let appGroup = "group.org.gbrown.wristcode" + /// `.org.gbrown.wristcode.shared`, from Info.plist, where the build fills in the team prefix. + static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "WristCodeKeychainGroup") as? String + ?? "GQJNM5YK2M.org.gbrown.wristcode.shared" private static let service = "org.gbrown.wristcode" private static let tokenAccount = "deviceToken" @@ -51,9 +52,9 @@ enum DeviceStore { /// them, so the app keeps working and only the complication goes without. static func migrateToSharedGroup() { for account in [tokenAccount, urlAccount] where readShared(account) == nil { - guard let legacy = readAnyGroup(account), legacy.group != appGroup else { continue } + guard let legacy = readAnyGroup(account), legacy.group != accessGroup else { continue } var item = query(account) - item[kSecAttrAccessGroup as String] = appGroup + item[kSecAttrAccessGroup as String] = accessGroup item[kSecValueData as String] = Data(legacy.value.utf8) item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue } @@ -89,7 +90,7 @@ enum DeviceStore { } private static func sharedQuery(_ account: String) -> [String: Any] { - query(account).merging([kSecAttrAccessGroup as String: appGroup]) { _, new in new } + query(account).merging([kSecAttrAccessGroup as String: accessGroup]) { _, new in new } } /// The shared item, or failing that one an older build left in the app's own group. @@ -98,14 +99,34 @@ enum DeviceStore { } private static func readShared(_ account: String) -> String? { + sharedData(account).flatMap { String(data: $0, encoding: .utf8) } + } + + /// An item in the shared group only. `SharedState` keeps its values here too. + static func sharedData(_ account: String) -> Data? { var query = sharedQuery(account) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data - else { return nil } - return String(data: data, encoding: .utf8) + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil } + return result as? Data + } + + /// Saves or, given nil, deletes an item in the shared group. + static func setSharedData(_ data: Data?, account: String) { + guard let data else { + SecItemDelete(sharedQuery(account) as CFDictionary) + return + } + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + let item = sharedQuery(account).merging(attributes) { _, new in new } + SecItemAdd(item as CFDictionary, nil) + } } /// An item in any access group this process can read, with the group it is in. @@ -124,16 +145,7 @@ enum DeviceStore { } private static func write(_ value: String, account: String) { - let data = Data(value.utf8) - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary) - if status == errSecItemNotFound { - let item = sharedQuery(account).merging(attributes) { _, new in new } - SecItemAdd(item as CFDictionary, nil) - } + setSharedData(Data(value.utf8), account: account) } /// Deletes the item from every access group, so a leftover from an older build goes too. diff --git a/watch/WristCode/Info.plist b/watch/WristCode/Info.plist index 540e621..7358d97 100644 --- a/watch/WristCode/Info.plist +++ b/watch/WristCode/Info.plist @@ -37,5 +37,7 @@ WKWatchOnly + WristCodeKeychainGroup + $(AppIdentifierPrefix)org.gbrown.wristcode.shared diff --git a/watch/WristCode/Shared/SharedState.swift b/watch/WristCode/Shared/SharedState.swift index 9fbfc95..8cd8c82 100644 --- a/watch/WristCode/Shared/SharedState.swift +++ b/watch/WristCode/Shared/SharedState.swift @@ -1,22 +1,22 @@ -// What the app tells the complication through the App Group's defaults: whether it is in demo mode, -// and the last summary it saw, which the complication shows when it cannot reach the gateway. +// What the app tells the complication besides the credentials: whether it is in demo mode, and the +// last summary it saw, which the complication shows when it cannot reach the gateway. Both live in +// the shared Keychain group next to the device token, the one store the two processes share. import Foundation enum SharedState { - private static var defaults: UserDefaults { UserDefaults(suiteName: DeviceStore.appGroup) ?? .standard } - private static let demoKey = "demo" - private static let summaryKey = "summary" + private static let demoAccount = "demo" + private static let summaryAccount = "lastSummary" /// True while the app is in demo mode, so the complication shows the demo counts too. static var demo: Bool { - get { defaults.bool(forKey: demoKey) } - set { defaults.set(newValue, forKey: demoKey) } + get { DeviceStore.sharedData(demoAccount) == Data([1]) } + set { DeviceStore.setSharedData(newValue ? Data([1]) : nil, account: demoAccount) } } /// The last summary the app or the complication saw, or nil after signing out. static var lastSummary: Summary? { - get { defaults.data(forKey: summaryKey).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } } - set { defaults.set(newValue.flatMap { try? JSON.encoder().encode($0) }, forKey: summaryKey) } + get { DeviceStore.sharedData(summaryAccount).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } } + set { DeviceStore.setSharedData(newValue.flatMap { try? JSON.encoder().encode($0) }, account: summaryAccount) } } } diff --git a/watch/WristCode/WristCode.entitlements b/watch/WristCode/WristCode.entitlements index 469b974..bb6b927 100644 --- a/watch/WristCode/WristCode.entitlements +++ b/watch/WristCode/WristCode.entitlements @@ -4,9 +4,9 @@ aps-environment development - com.apple.security.application-groups + keychain-access-groups - group.org.gbrown.wristcode + $(AppIdentifierPrefix)org.gbrown.wristcode.shared diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index 98ad885..2f90138 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -2,6 +2,7 @@ // the screens rely on. import Foundation +import Security import Testing @testable import WristCode @@ -260,6 +261,67 @@ private let feedItemJSON = """ } } +/// Reads and writes the real shared Keychain group, so the entitlement and Info.plist group agree. +@Suite(.serialized) struct SharedStateTests { + @Test func accessGroupHasTeamPrefix() { + #expect(DeviceStore.accessGroup.hasSuffix(".org.gbrown.wristcode.shared")) + #expect(!DeviceStore.accessGroup.hasPrefix("$")) + } + + @Test func roundTrip() { + let demo = SharedState.demo + let summary = SharedState.lastSummary + defer { + SharedState.demo = demo + SharedState.lastSummary = summary + } + + let saved = Summary(needsYou: 2, working: 1, next: nil, hostsOffline: 0) + SharedState.lastSummary = saved + #expect(SharedState.lastSummary == saved) + SharedState.lastSummary = nil + #expect(SharedState.lastSummary == nil) + + SharedState.demo = true + #expect(SharedState.demo) + SharedState.demo = false + #expect(!SharedState.demo) + } + + /// A token saved by a build before the complication sits in the app's own group. Launch moves it. + @Test func migratesLegacyItems() throws { + let before = DeviceStore.load() + defer { + if let before { DeviceStore.save(before) } else { DeviceStore.clearToken() } + } + let team = try #require(DeviceStore.accessGroup.split(separator: ".").first) + let appGroup = "\(team).org.gbrown.wristcode.watchkitapp" + func item(_ account: String) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "org.gbrown.wristcode", + kSecAttrAccount as String: account] + } + for (account, value) in [("deviceToken", "wc_legacy"), ("gatewayURL", "https://legacy.example")] { + SecItemDelete(item(account) as CFDictionary) + var legacy = item(account) + legacy[kSecAttrAccessGroup as String] = appGroup + legacy[kSecValueData as String] = Data(value.utf8) + #expect(SecItemAdd(legacy as CFDictionary, nil) == errSecSuccess) + } + #expect(DeviceStore.sharedData("deviceToken") == nil) + // Before the move the app still finds the old items. + #expect(DeviceStore.load()?.token == "wc_legacy") + + DeviceStore.migrateToSharedGroup() + + #expect(DeviceStore.sharedData("deviceToken") == Data("wc_legacy".utf8)) + #expect(DeviceStore.load() == Credentials(gatewayURL: URL(string: "https://legacy.example")!, token: "wc_legacy")) + var old = item("deviceToken") + old[kSecAttrAccessGroup as String] = appGroup + #expect(SecItemCopyMatching(old as CFDictionary, nil) == errSecItemNotFound) + } +} + @Suite struct DemoGatewayTests { @Test func allowMovesThreadToWorking() async throws { let gateway = DemoGateway() diff --git a/watch/WristCodeWidget/Info.plist b/watch/WristCodeWidget/Info.plist index 5b0aaf1..2a33f0e 100644 --- a/watch/WristCodeWidget/Info.plist +++ b/watch/WristCodeWidget/Info.plist @@ -25,5 +25,7 @@ NSExtensionPointIdentifier com.apple.widgetkit-extension + WristCodeKeychainGroup + $(AppIdentifierPrefix)org.gbrown.wristcode.shared diff --git a/watch/WristCodeWidget/WaitingWidget.swift b/watch/WristCodeWidget/WaitingWidget.swift index a43679c..331a02c 100644 --- a/watch/WristCodeWidget/WaitingWidget.swift +++ b/watch/WristCodeWidget/WaitingWidget.swift @@ -1,6 +1,6 @@ // The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It -// reads the device token from the shared Keychain group and the demo flag from the App Group, and -// the app reloads it whenever its own feed or a notification changes the counts. +// reads the device token and the demo flag from the shared Keychain group, and the app reloads it +// whenever its own feed or a notification changes the counts. import SwiftUI import WidgetKit diff --git a/watch/WristCodeWidget/WristCodeWidget.entitlements b/watch/WristCodeWidget/WristCodeWidget.entitlements index bbf03d5..d545986 100644 --- a/watch/WristCodeWidget/WristCodeWidget.entitlements +++ b/watch/WristCodeWidget/WristCodeWidget.entitlements @@ -2,9 +2,9 @@ - com.apple.security.application-groups + keychain-access-groups - group.org.gbrown.wristcode + $(AppIdentifierPrefix)org.gbrown.wristcode.shared diff --git a/watch/project.yml b/watch/project.yml index 2ffb91e..5d5d004 100644 --- a/watch/project.yml +++ b/watch/project.yml @@ -41,15 +41,17 @@ targets: CFBundleURLTypes: - CFBundleURLName: org.gbrown.wristcode CFBundleURLSchemes: [wristcode] + WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared entitlements: path: WristCode/WristCode.entitlements properties: # Archives are signed for development; exporting for App Store Connect re-signs with the # distribution profile, which turns this into production. aps-environment: development - # Shared with the complication: the App Group's defaults, and its Keychain access group - # for the device token. - com.apple.security.application-groups: [group.org.gbrown.wristcode] + # Shared with the complication: the device token, gateway URL, demo flag and last summary. + # A Keychain group rather than an App Group, because automatic signing with an App Store + # Connect API key can register IDs and profiles but not App Groups. + keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared] settings: base: # The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also @@ -57,7 +59,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # The complication. It shares the API client, models, Keychain store and demo fixtures with the - # app, and reads the device token through the App Group. + # app, and reads the device token through the shared Keychain group. WristCodeWidget: type: app-extension platform: watchOS @@ -76,10 +78,11 @@ targets: CFBundleVersion: $(CURRENT_PROJECT_VERSION) NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension + WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared entitlements: path: WristCodeWidget/WristCodeWidget.entitlements properties: - com.apple.security.application-groups: [group.org.gbrown.wristcode] + keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared] settings: base: # Extensions take the watch app's bundle ID as a prefix.