Files
yattee/Yattee/Services/Credentials/InvidiousCredentialsManager.swift
Arkadiusz Fal 9fd0a71035 Add identity-scoped, account-keyed credential storage
Yattee 2 keys credentials by instance UUID with no notion of which Apple
account they belong to, and reads them back with SynchronizableAny. Apple
documents that Keychain data may stay on device when a user signs out and
chooses to keep it, so whoever signs in next would be served the previous
user's credential - guaranteed for migrated accounts, since local instances
survive the switch and the migrated account UUID is deterministically the
instance UUID.

Credentials are now keyed "<identityScope>|<accountID>", where the scope is
the CloudKit user record (identical across a user's devices, so iCloud
Keychain sync still works). Usernames get the same scope and live beside the
credential, keeping login identifiers out of synced metadata.

The legacy unscoped item is never adopted automatically. A strict
kSecAttrSynchronizable == true query was the candidate discriminator for
"this secret belongs to the current Apple account", but measured on iPadOS
27.0 a retained item keeps the attribute and still matches that query with
no Apple account signed in at all - the flag records what was once requested,
not live iCloud Keychain membership. Reads therefore never fall back to the
legacy key; adoption is an explicit user action that copies the item,
verifies the scoped read-back, then records a marker bound to the identity
and a salted fingerprint of the secret. The legacy item is retained, because
deleting a synchronizable item propagates and would log out devices still on
the previous release mid-rollout. Deleting it later needs all three of: a
marker on this device, matching identity, and the item still holding the
adopted secret.

The instance-keyed path is untouched, so no shipping call site changes
behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth
belongs to the instance, not to a user account.

While the scope is unresolved (first launch under a new Apple account,
CloudKit unreachable) reads report "can't tell yet" via isScopeResolved
rather than "signed out", and writes are refused rather than guessing a
scope. Login state is cached per scope so one identity's cache can never
answer for another.

Also adds the debug-only harness the measurement was taken with, under
Advanced > Developer > Experiments.
2026-08-23 21:20:30 +02:00

317 lines
11 KiB
Swift

//
// InvidiousCredentialsManager.swift
// Yattee
//
// Manages Invidious session credentials stored securely in the Keychain.
//
import Foundation
import Security
/// Manages Invidious session IDs (SID) stored securely in the Keychain.
@MainActor
@Observable
final class InvidiousCredentialsManager: InstanceCredentialsManager {
private static let keychainServiceName = "com.yattee.invidious"
private let thumbnailCacheKey = "com.yattee.invidious.channelThumbnails"
/// Tracks which instances have stored sessions (for reactive UI updates)
private(set) var loggedInInstanceIDs: Set<UUID> = []
/// In-memory cache for channel thumbnails (channelID -> URL string)
private var thumbnailCache: [String: String] = [:]
/// Account-keyed, identity-scoped storage — the multi-login path.
///
/// The instance-keyed members below stay for call sites that still think in
/// instances; they read and write the unscoped legacy item unchanged, so an
/// upgrade never silently moves a credential.
let accountCredentials: AccountKeychainCredentials
/// Reference to settings manager for cleanup on logout and iCloud sync decisions
weak var settingsManager: SettingsManager? {
didSet { accountCredentials.settingsManager = settingsManager }
}
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances).
private var shouldSyncToiCloud: Bool {
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true
}
init(identityScope: CredentialIdentityScope? = nil) {
accountCredentials = AccountKeychainCredentials(
service: Self.keychainServiceName,
credentialNoun: "SID",
identityScope: identityScope ?? CredentialIdentityScope()
)
loadThumbnailCache()
}
// MARK: - Thumbnail Cache
/// Gets cached thumbnail URL for a channel.
func thumbnailURL(forChannelID channelID: String) -> URL? {
guard let urlString = thumbnailCache[channelID] else { return nil }
return URL(string: urlString)
}
/// Caches a thumbnail URL for a channel.
func setThumbnailURL(_ url: URL, forChannelID channelID: String) {
thumbnailCache[channelID] = url.absoluteString
saveThumbnailCache()
}
/// Caches multiple thumbnail URLs at once.
func setThumbnailURLs(_ thumbnails: [String: URL]) {
for (channelID, url) in thumbnails {
thumbnailCache[channelID] = url.absoluteString
}
saveThumbnailCache()
}
/// Returns channel IDs that are not in the cache.
func uncachedChannelIDs(from channelIDs: [String]) -> [String] {
channelIDs.filter { thumbnailCache[$0] == nil }
}
/// Clears the thumbnail cache.
func clearThumbnailCache() {
thumbnailCache.removeAll()
UserDefaults.standard.removeObject(forKey: thumbnailCacheKey)
}
private func loadThumbnailCache() {
if let data = UserDefaults.standard.data(forKey: thumbnailCacheKey),
let cache = try? JSONDecoder().decode([String: String].self, from: data) {
thumbnailCache = cache
}
}
private func saveThumbnailCache() {
if let data = try? JSONEncoder().encode(thumbnailCache) {
UserDefaults.standard.set(data, forKey: thumbnailCacheKey)
}
}
// MARK: - Public API
/// Stores the SID (session ID) for an Invidious instance.
/// SID syncs to iCloud Keychain when iCloud sync is enabled for instances.
/// - Parameters:
/// - sid: The session ID cookie value
/// - instance: The Invidious instance
func setSID(_ sid: String, for instance: Instance) {
let account = instance.id.uuidString
guard let data = sid.data(using: .utf8) else {
LoggingService.shared.error("Failed to encode SID data", category: .keychain)
return
}
let syncEnabled = shouldSyncToiCloud
// First, delete any existing item (both synced and non-synced) to avoid duplicates
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
SecItemDelete(deleteQuery as CFDictionary)
// Create new item with current sync preference
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: syncEnabled,
kSecValueData as String: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecSuccess {
LoggingService.shared.info(
"Stored SID for Invidious instance",
category: .keychain,
details: "instanceID=\(instance.id), iCloudSync=\(syncEnabled)"
)
// Update tracked set for reactive UI
loggedInInstanceIDs.insert(instance.id)
} else {
LoggingService.shared.error(
"Failed to store SID for Invidious instance",
category: .keychain,
details: "instanceID=\(instance.id), status=\(status)"
)
}
}
/// Retrieves the SID for an Invidious instance.
/// Searches both synced and non-synced items.
/// - Parameter instance: The Invidious instance
/// - Returns: The session ID if stored, nil otherwise
func sid(for instance: Instance) -> String? {
let account = instance.id.uuidString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let sid = String(data: data, encoding: .utf8) else {
LoggingService.shared.debug(
"No SID found for Invidious instance",
category: .keychain,
details: "instanceID=\(instance.id), status=\(status)"
)
return nil
}
LoggingService.shared.debug(
"Retrieved SID for Invidious instance",
category: .keychain,
details: "instanceID=\(instance.id)"
)
return sid
}
/// Deletes the SID for an instance (logout).
/// Deletes both synced and non-synced items.
/// - Parameter instance: The Invidious instance
func deleteSID(for instance: Instance) {
let account = instance.id.uuidString
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.keychainServiceName,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
LoggingService.shared.info(
"Deleted SID for Invidious instance",
category: .keychain,
details: "instanceID=\(instance.id)"
)
} else {
LoggingService.shared.error(
"Failed to delete SID for Invidious instance",
category: .keychain,
details: "instanceID=\(instance.id), status=\(status)"
)
}
// Update tracked set for reactive UI
loggedInInstanceIDs.remove(instance.id)
// Clear Feed cache (but keep Feed items in Home settings - toggle will be disabled)
HomeInstanceCache.shared.clear(instanceID: instance.id, contentType: .feed)
}
/// Checks if an instance has a stored session.
/// - Parameter instance: The Invidious instance
/// - Returns: true if logged in, false otherwise
func isLoggedIn(for instance: Instance) -> Bool {
// Check tracked set first for performance
if loggedInInstanceIDs.contains(instance.id) {
return true
}
// Fall back to Keychain check
let hasSession = sid(for: instance) != nil
if hasSession {
loggedInInstanceIDs.insert(instance.id)
}
return hasSession
}
/// Refreshes the logged-in status for an instance.
/// Call this when the view appears to ensure UI is in sync.
func refreshLoginStatus(for instance: Instance) {
if sid(for: instance) != nil {
loggedInInstanceIDs.insert(instance.id)
} else {
loggedInInstanceIDs.remove(instance.id)
}
}
// MARK: - InstanceCredentialsManager Protocol
/// Stores a credential (SID) for an Invidious instance.
/// Protocol conformance - delegates to setSID.
func setCredential(_ credential: String, for instance: Instance) {
setSID(credential, for: instance)
}
/// Retrieves the stored credential (SID) for an Invidious instance.
/// Protocol conformance - delegates to sid(for:).
func credential(for instance: Instance) -> String? {
sid(for: instance)
}
/// Deletes the stored credential (SID) for an Invidious instance.
/// Protocol conformance - delegates to deleteSID.
func deleteCredential(for instance: Instance) {
deleteSID(for: instance)
}
}
// MARK: - AccountCredentialsManager
extension InvidiousCredentialsManager: AccountCredentialsManager {
var loggedInAccountIDs: Set<UUID> { accountCredentials.loggedInAccountIDs }
var isScopeResolved: Bool { accountCredentials.isScopeResolved }
func setCredential(_ credential: String, for account: Account) {
accountCredentials.setCredential(credential, for: account)
}
func credential(for account: Account) -> String? {
accountCredentials.credential(for: account)
}
func deleteCredential(for account: Account) {
accountCredentials.deleteCredential(for: account)
}
func setUsername(_ username: String, for account: Account) {
accountCredentials.setUsername(username, for: account)
}
func username(for account: Account) -> String? {
accountCredentials.username(for: account)
}
func isLoggedIn(for account: Account) -> Bool {
accountCredentials.isLoggedIn(for: account)
}
func refreshLoginStatus(for account: Account) {
accountCredentials.refreshLoginStatus(for: account)
}
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
accountCredentials.hasAdoptableLegacyCredential(for: account)
}
@discardableResult
func adoptLegacyCredential(for account: Account) -> Bool {
accountCredentials.adoptLegacyCredential(for: account)
}
func deleteLegacyCredentialIfOwned(for account: Account) {
accountCredentials.deleteLegacyCredentialIfOwned(for: account)
}
}