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) <noreply@anthropic.com>
132 lines
4.8 KiB
Swift
132 lines
4.8 KiB
Swift
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
|
|
}
|
|
}
|