From a70f7fa5e835bf97e739596c552500351070e02c Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 17 May 2026 18:19:43 +0200 Subject: [PATCH] fix(keychain): Migration-Fallback bei accessGroup-Wechsel (v1.5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bis jetzt haben die mana-Apps `keychainAccessGroup: nil` gesetzt; Apple legt das Item dann im default-bucket (`$(AppIdentifierPrefix). $(BundleId)`) ab. Beim Wechsel auf eine explizite `accessGroup` (Apps werden mit v1.5.1 nachgezogen) hätten User sonst beim ersten Start einen Logout gesehen — Apple's Read-mit-Group liefert das alte Item nicht immer. KeychainStore.getString(for:) liest jetzt bei einem Miss einmalig ohne `kSecAttrAccessGroup` nach. Findet sich der alte Eintrag im default-bucket, wird er in den expliziten Bucket migriert und der alte gelöscht. Transparent für alle Caller. Greift nur wenn accessGroup != nil — Apps die nicht migrieren, sind unaffected. 70/70 ManaCore-Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 25 +++++++++++++++++ Sources/ManaCore/Auth/KeychainStore.swift | 34 +++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30501d3..e22531f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an [Keep a Changelog](https://keepachangelog.com), Versionierung nach [Semver](https://semver.org). +## [1.5.1] — 2026-05-17 + +Patch — KeychainStore-Migration-Fallback. Bisher haben die mana-Apps +`keychainAccessGroup: nil` gesetzt; Apple legt das Item dann im +default-bucket (`$(AppIdentifierPrefix).$(BundleId)`) ab. Beim +Wechsel auf eine explizite `accessGroup` (Apps werden mit v1.5.1 +nachgezogen) hätten User sonst beim ersten Start einen Logout +gesehen, weil Apples Read-mit-Group das alte Item nicht immer +liefert. + +### Verändert + +- `KeychainStore.getString(for:)` — bei `accessGroup != nil` und + einem Miss wird einmalig ohne `kSecAttrAccessGroup` re-queryt. + Findet sich der alte Eintrag im default-bucket, wird er in den + expliziten Bucket migriert und der alte gelöscht. Transparent für + alle Caller — `getString` liefert wie gewohnt den Wert zurück. + +### Migrations-Hinweis + +- Apps, die `keychainAccessGroup` ab v1.5.1 explizit setzen, brauchen + keinen Logout zu erwarten. Apps, die weiterhin `nil` setzen, sind + von dem Fallback nicht betroffen (er greift nur bei `accessGroup + != nil`). + ## [1.5.0] — 2026-05-14 Minor — `getProfile()` + `ProfileInfo`. Apps können den 2FA-Status diff --git a/Sources/ManaCore/Auth/KeychainStore.swift b/Sources/ManaCore/Auth/KeychainStore.swift index b9e953e..913ed24 100644 --- a/Sources/ManaCore/Auth/KeychainStore.swift +++ b/Sources/ManaCore/Auth/KeychainStore.swift @@ -61,10 +61,40 @@ public final class KeychainStore: Sendable { query[kSecMatchLimit] = kSecMatchLimitOne var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { + 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 } - return String(data: data, encoding: .utf8) + 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) {