diff --git a/Calendarr iOS/Calendarr iOS.entitlements b/Calendarr iOS/Calendarr iOS.entitlements index bb88d59..5a5e5d8 100644 --- a/Calendarr iOS/Calendarr iOS.entitlements +++ b/Calendarr iOS/Calendarr iOS.entitlements @@ -6,5 +6,15 @@ group.com.scarriffleservices.calendarr + + keychain-access-groups + + $(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios + $(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared + diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index fe39d08..ede36bf 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -1,5 +1,4 @@ import SwiftUI -import Security @main struct CalendarrApp: App { @@ -25,17 +24,45 @@ class AppState { init() { serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" - // Migrate a token previously kept in UserDefaults into the Keychain once, - // so existing logins survive the change without re-authenticating. - if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty { - Keychain.set(legacy, for: "authToken") - UserDefaults.standard.removeObject(forKey: "authToken") - } - authToken = Keychain.get("authToken") ?? "" + authToken = Self.loadToken() username = UserDefaults.standard.string(forKey: "username") ?? "" isAdmin = UserDefaults.standard.bool(forKey: "isAdmin") } + /// Find the stored token, migrating it forward from wherever an older build + /// left it. Runs on every launch but does real work only once. + /// + /// Step 2 is the one that keeps existing users signed in. Before the + /// `keychain-access-groups` entitlement existed, items landed in the app's + /// own default group. Adding the entitlement makes the *first* array entry + /// the new default — and that entry is deliberately the app's own group, so + /// an unqualified query still resolves those items and we can copy them + /// across instead of stranding them. + private static func loadToken() -> String { + let key = "authToken" + + // 1. Already in the shared group — the steady state. + if let token = try? KeychainStore.get(key), !token.isEmpty { + return token + } + + // 2. In the app's own default group, written before the entitlement. + if let token = try? KeychainStore.get(key, accessGroup: nil), !token.isEmpty { + try? KeychainStore.set(token, for: key) + try? KeychainStore.set(nil, for: key, accessGroup: nil) + return token + } + + // 3. In UserDefaults, written before secrets moved to the Keychain. + if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty { + try? KeychainStore.set(legacy, for: key) + UserDefaults.standard.removeObject(forKey: key) + return legacy + } + + return "" + } + func saveServer(url: String) { serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines) if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) } @@ -46,7 +73,7 @@ class AppState { authToken = token username = user isAdmin = admin - Keychain.set(token, for: "authToken") // secret → Keychain, not UserDefaults + try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults UserDefaults.standard.set(user, forKey: "username") UserDefaults.standard.set(admin, forKey: "isAdmin") } @@ -55,10 +82,14 @@ class AppState { authToken = "" username = "" isAdmin = false - Keychain.set(nil, for: "authToken") - UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy + try? KeychainStore.set(nil, for: "authToken") + try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy + UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy UserDefaults.standard.removeObject(forKey: "username") UserDefaults.standard.removeObject(forKey: "isAdmin") + // The shared container outlives the session, so it has to be cleared + // explicitly — otherwise widgets keep showing the signed-out user's data. + WidgetStore.clear() } func resetServer() { @@ -67,37 +98,3 @@ class AppState { UserDefaults.standard.removeObject(forKey: "serverURL") } } - -/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are -/// stored as generic passwords, accessible after first unlock. -enum Keychain { - private static let service = "Calendarr" - - static func set(_ value: String?, for key: String) { - let base: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(base as CFDictionary) - guard let value, let data = value.data(using: .utf8) else { return } - var add = base - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - SecItemAdd(add as CFDictionary, nil) - } - - static func get(_ key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var out: AnyObject? - guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, - let data = out as? Data else { return nil } - return String(data: data, encoding: .utf8) - } -} diff --git a/Calendarr iOS/Services/KeychainStore.swift b/Calendarr iOS/Services/KeychainStore.swift new file mode 100644 index 0000000..f5faf98 --- /dev/null +++ b/Calendarr iOS/Services/KeychainStore.swift @@ -0,0 +1,131 @@ +import Foundation +import Security + +/// Keychain access for secrets (currently just the auth bearer token). +/// +/// This replaces the earlier `enum Keychain`, which discarded every `OSStatus`. +/// That mattered more than it looks: on Mac Catalyst a missing +/// `keychain-access-groups` entitlement makes `SecItemAdd`/`SecItemCopyMatching` +/// fail with `errSecMissingEntitlement`, and because nothing checked the status +/// the result was indistinguishable from "no token stored" — the user was +/// silently signed out on every launch. +/// +/// Items live in a shared access group so the Mac build and other apps from the +/// same team can read the token. Reading requires `keychain-access-groups` in +/// the entitlements listing `sharedAccessGroup`. +enum KeychainStore { + /// Access group shared across our apps. Must match an entry in the + /// `keychain-access-groups` entitlement of every app that uses it. + /// The `PP34X97WS3.` prefix is this account's Team ID — the same value + /// `$(AppIdentifierPrefix)` expands to at build time. + static let sharedAccessGroup = "PP34X97WS3.com.scarriffleservices.calendarr.shared" + + private static let service = "Calendarr" + + /// Set when a keychain call failed because the entitlement was missing and + /// we fell back to the app's own default access group. Signing is a build + /// configuration concern, so in DEBUG we make it loud; in release we keep + /// working rather than locking the user out of their own account. + private(set) nonisolated(unsafe) static var didFallBackToDefaultGroup = false + + // MARK: - Public API + + /// Store or delete a value. Passing `nil` deletes it. + static func set(_ value: String?, for key: String, + accessGroup: String? = sharedAccessGroup) throws { + do { + try write(value, key: key, accessGroup: accessGroup) + } catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil { + noteEntitlementFallback() + try write(value, key: key, accessGroup: nil) + } + } + + /// Read a value. Returns `nil` when the item simply is not there; + /// throws when the keychain itself refused the request. + static func get(_ key: String, accessGroup: String? = sharedAccessGroup) throws -> String? { + do { + return try read(key: key, accessGroup: accessGroup) + } catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil { + noteEntitlementFallback() + return try read(key: key, accessGroup: nil) + } + } + + // MARK: - Implementation + + private static func baseQuery(key: String, accessGroup: String?) -> [String: Any] { + var q: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + // Selects the modern, entitlement-gated keychain on macOS/Catalyst + // instead of the legacy file-based login keychain. Documented no-op + // on iOS 13+, so it is safe to set unconditionally. + kSecUseDataProtectionKeychain as String: true, + ] + if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup } + return q + } + + private static func write(_ value: String?, key: String, accessGroup: String?) throws { + let base = baseQuery(key: key, accessGroup: accessGroup) + + let deleteStatus = SecItemDelete(base as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw KeychainError.status(deleteStatus, operation: "delete \(key)") + } + + guard let value, let data = value.data(using: .utf8) else { return } + + var add = base + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError.status(addStatus, operation: "add \(key)") + } + } + + private static func read(key: String, accessGroup: String?) throws -> String? { + var query = baseQuery(key: key, accessGroup: accessGroup) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var out: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &out) + switch status { + case errSecSuccess: + guard let data = out as? Data else { return nil } + return String(data: data, encoding: .utf8) + case errSecItemNotFound: + return nil + default: + throw KeychainError.status(status, operation: "read \(key)") + } + } + + private static func noteEntitlementFallback() { + guard !didFallBackToDefaultGroup else { return } + didFallBackToDefaultGroup = true + assertionFailure(""" + Keychain access group \(sharedAccessGroup) was refused \ + (errSecMissingEntitlement). Add it to keychain-access-groups and \ + enable Keychain Sharing on the App ID. Falling back to the app's \ + own default group — the Mac app will not see this token. + """) + } +} + +enum KeychainError: Error, LocalizedError { + case status(OSStatus, operation: String) + + var errorDescription: String? { + switch self { + case let .status(status, operation): + let detail = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)" + return "Keychain \(operation) failed: \(detail)" + } + } +} diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift index a623aed..b78bcf6 100644 --- a/Shared/WidgetData.swift +++ b/Shared/WidgetData.swift @@ -152,6 +152,17 @@ enum WidgetStore { return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? [] } + /// Drop the cached snapshot and calendar list. Called on logout and on + /// server reset: without this the files survive, and widgets — plus any + /// other app reading the group container — keep rendering the previous + /// user's events indefinitely. + static func clear() { + for url in [cacheURL, calendarsURL].compactMap({ $0 }) { + try? FileManager.default.removeItem(at: url) + } + WidgetTimelineNotifier.reload() + } + /// Rewrite the existing snapshot with the latest colour / language values /// from UserDefaults. Used when the user tweaks an appearance setting and /// we want the widgets to refresh immediately, without needing a new event