import Foundation import Security /// Generische, in der App injizierbare Keychain-Wrapper. Eine Instanz /// pro `ManaAppConfig`. public final class KeychainStore: Sendable { /// Bekannte Keys für mana-auth-Tokens. /// /// `.guestId` ist eine lokal generierte anonyme UUID für Apps mit /// Local-First-/Guest-Modus. Sie wird unabhängig von den Session- /// Tokens persistiert und vom Default-``wipe()`` *nicht* gelöscht — /// damit eine App nach einem Logout im Guest-Modus weiterlaufen /// kann, ohne dass die lokalen anonymen Daten ihre Besitzer-Spur /// verlieren. Vollständiger Reset über ``wipeAll()``. public enum Key: String, Sendable { case accessToken = "access_token" case refreshToken = "refresh_token" case email case guestId = "guest_id" } private let service: String private let accessGroup: String? public init(service: String, accessGroup: String? = nil) { self.service = service self.accessGroup = accessGroup } public func setString(_ value: String, for key: Key) throws { guard let data = value.data(using: .utf8) else { throw AuthError.encoding } var query = baseQuery(for: key) let attributes: [CFString: Any] = [ kSecValueData: data, kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, ] let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) switch updateStatus { case errSecSuccess: return case errSecItemNotFound: for (attrKey, attrValue) in attributes { query[attrKey] = attrValue } let addStatus = SecItemAdd(query as CFDictionary, nil) guard addStatus == errSecSuccess else { throw AuthError.keychain(addStatus) } default: throw AuthError.keychain(updateStatus) } } public func getString(for key: Key) -> String? { var query = baseQuery(for: key) query[kSecReturnData] = true query[kSecMatchLimit] = kSecMatchLimitOne var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecSuccess, let data = result as? Data { return String(data: data, encoding: .utf8) } // One-shot migration: bis v1.2.0 haben die mana-Apps Keychain- // Items ohne explizite `kSecAttrAccessGroup` geschrieben. Apple // hat sie dann im default-bucket (= `$(AppIdentifierPrefix).$(BundleId)`) // gelandet. Ab v1.2.1 setzen die Apps die accessGroup explizit // auf denselben Identifier. In den meisten Fällen liefert Apple // das alte Item beim Read-mit-Group transparent zurück — wenn // nicht, hat das einen Logout zur Folge. Dieser Fallback liest // den Item nochmal ohne accessGroup, schreibt ihn beim Erfolg // mit accessGroup neu in den expliziten Bucket und löscht den // alten Eintrag. guard accessGroup != nil else { return nil } var fallback: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: key.rawValue, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] var fallbackResult: AnyObject? guard SecItemCopyMatching(fallback as CFDictionary, &fallbackResult) == errSecSuccess, let data = fallbackResult as? Data, let value = String(data: data, encoding: .utf8) else { return nil } try? setString(value, for: key) fallback.removeValue(forKey: kSecReturnData) fallback.removeValue(forKey: kSecMatchLimit) SecItemDelete(fallback as CFDictionary) return value } public func remove(for key: Key) { let query = baseQuery(for: key) SecItemDelete(query as CFDictionary) } /// Löscht Session-Daten (accessToken, refreshToken, email). Behält /// die ``Key/guestId``, damit lokale Guest-Daten nach einem Logout /// erhalten bleiben können. public func wipe() { remove(for: .accessToken) remove(for: .refreshToken) remove(for: .email) } /// Löscht *alles* inklusive der Guest-Identität. Genutzt nach /// `deleteAccount()` oder bei explizitem "anonyme Daten vergessen". public func wipeAll() { wipe() remove(for: .guestId) } private func baseQuery(for key: Key) -> [CFString: Any] { var query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: key.rawValue, ] if let accessGroup { query[kSecAttrAccessGroup] = accessGroup } return query } }