mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
246 lines
8.8 KiB
Swift
246 lines
8.8 KiB
Swift
//
|
|
// PipedCredentialsManager.swift
|
|
// Yattee
|
|
//
|
|
// Manages Piped authentication tokens stored securely in the Keychain.
|
|
//
|
|
|
|
import Foundation
|
|
import Security
|
|
|
|
/// Manages Piped auth tokens stored securely in the Keychain.
|
|
@MainActor
|
|
@Observable
|
|
final class PipedCredentialsManager: InstanceCredentialsManager {
|
|
private static let keychainServiceName = "com.yattee.piped"
|
|
|
|
/// Tracks which instances have stored tokens (for reactive UI updates)
|
|
private(set) var loggedInInstanceIDs: Set<UUID> = []
|
|
|
|
/// 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 iCloud sync decisions
|
|
weak var settingsManager: SettingsManager? {
|
|
didSet { accountCredentials.settingsManager = settingsManager }
|
|
}
|
|
|
|
init(identityScope: CredentialIdentityScope? = nil) {
|
|
accountCredentials = AccountKeychainCredentials(
|
|
service: Self.keychainServiceName,
|
|
credentialNoun: "token",
|
|
identityScope: identityScope ?? CredentialIdentityScope()
|
|
)
|
|
}
|
|
|
|
/// Whether credentials should sync to iCloud Keychain (when iCloud sync is enabled for instances).
|
|
private var shouldSyncToiCloud: Bool {
|
|
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true
|
|
}
|
|
|
|
// MARK: - InstanceCredentialsManager Protocol
|
|
|
|
/// Stores the auth token for a Piped instance.
|
|
/// Token syncs to iCloud Keychain when iCloud sync is enabled for instances.
|
|
/// - Parameters:
|
|
/// - credential: The auth token from login
|
|
/// - instance: The Piped instance
|
|
func setCredential(_ credential: String, for instance: Instance) {
|
|
let account = instance.id.uuidString
|
|
guard let data = credential.data(using: .utf8) else {
|
|
LoggingService.shared.error("Failed to encode token 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 token for Piped 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 token for Piped instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id), status=\(status)"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Retrieves the auth token for a Piped instance.
|
|
/// Searches both synced and non-synced items.
|
|
/// - Parameter instance: The Piped instance
|
|
/// - Returns: The auth token if stored, nil otherwise
|
|
func credential(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 token = String(data: data, encoding: .utf8) else {
|
|
LoggingService.shared.debug(
|
|
"No token found for Piped instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id), status=\(status)"
|
|
)
|
|
return nil
|
|
}
|
|
|
|
LoggingService.shared.debug(
|
|
"Retrieved token for Piped instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id)"
|
|
)
|
|
return token
|
|
}
|
|
|
|
/// Deletes the auth token for an instance (logout).
|
|
/// Deletes both synced and non-synced items.
|
|
/// - Parameter instance: The Piped instance
|
|
func deleteCredential(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 token for Piped instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id)"
|
|
)
|
|
} else {
|
|
LoggingService.shared.error(
|
|
"Failed to delete token for Piped instance",
|
|
category: .keychain,
|
|
details: "instanceID=\(instance.id), status=\(status)"
|
|
)
|
|
}
|
|
|
|
// Update tracked set for reactive UI
|
|
loggedInInstanceIDs.remove(instance.id)
|
|
|
|
// Clear Feed cache on logout
|
|
HomeInstanceCache.shared.clear(instanceID: instance.id, contentType: .feed)
|
|
}
|
|
|
|
/// Checks if an instance has a stored token.
|
|
/// - Parameter instance: The Piped 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 hasToken = credential(for: instance) != nil
|
|
if hasToken {
|
|
loggedInInstanceIDs.insert(instance.id)
|
|
}
|
|
return hasToken
|
|
}
|
|
|
|
/// 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 credential(for: instance) != nil {
|
|
loggedInInstanceIDs.insert(instance.id)
|
|
} else {
|
|
loggedInInstanceIDs.remove(instance.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AccountCredentialsManager
|
|
|
|
extension PipedCredentialsManager: 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)
|
|
}
|
|
}
|