Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem gemeinsamen Repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
5.5 KiB
Swift
132 lines
5.5 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|