Make Keychain and shared-container state correct before the Mac port

Two failures here are silent rather than loud, which is why they have gone
unnoticed on iOS and would have been much harder to diagnose on a Mac.

The old `enum Keychain` discarded all four OSStatus results. On Mac Catalyst a
missing `keychain-access-groups` entitlement makes SecItem calls fail with
errSecMissingEntitlement, and because nothing checked, that was indistinguishable
from "no token stored" — the user would be signed out on every launch, with no
error anywhere. KeychainStore now checks every status, distinguishes
errSecItemNotFound from real failures, and sets kSecUseDataProtectionKeychain so
macOS selects the modern entitlement-gated keychain instead of the legacy login
keychain.

Adding the entitlement moves the default access group to the first array entry,
so that entry is deliberately the app's own group: existing tokens keep
resolving with an unqualified query and nobody is signed out. loadToken() then
migrates forward in three steps — shared group, own default group, and the
pre-Keychain UserDefaults copy.

If the entitlement is not provisioned yet, KeychainStore falls back to the
default group rather than throwing. A hard failure would make the app unusable
for everyone whose provisioning lags; the fallback asserts in DEBUG instead, so
a misconfiguration is loud in development and survivable in production.

Separately, logout() left widget-cache.json in the App Group container, so
widgets kept rendering the signed-out user's events indefinitely. That is an
existing iOS bug, and it would have leaked the same data to any other app
reading the container. WidgetStore.clear() now removes both cache files and
reloads the timelines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-10 16:31:06 +02:00
parent 554ad425b1
commit 781ccd1752
4 changed files with 194 additions and 45 deletions

View File

@@ -6,5 +6,15 @@
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
<!-- Order matters. Without this entitlement the default access group is
<AppIdentifierPrefix><bundle id>, which is where every existing token
lives. Adding the entitlement makes the FIRST entry the new default, so
listing our own group first keeps those tokens resolving and nobody
gets signed out. The .shared group is the one the Mac app also reads. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
</array>
</dict>
</plist>

View File

@@ -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)
}
}

View File

@@ -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)"
}
}
}

View File

@@ -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