mana-swift-core/Sources/ManaCore/Auth/KeychainStore.swift
Till JS a70f7fa5e8 fix(keychain): Migration-Fallback bei accessGroup-Wechsel (v1.5.1)
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>
2026-05-17 18:19:43 +02:00

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
}
}