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:
131
Calendarr iOS/Services/KeychainStore.swift
Normal file
131
Calendarr iOS/Services/KeychainStore.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user