mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Four issues from an external review of the previous commit. The scope was resolved once at startup, so signing out or switching Apple account while the app kept running left it pinned to the old identity - reads and writes would then reach the previous account's retained credentials, which is the exact leak scoping exists to prevent. CredentialIdentityScope now observes NSUbiquityIdentityDidChange and re-resolves. Legacy deletion authority checked only the instance and the identity, not the account. On an instance with several accounts, deleting any of them satisfied the marker lookup and could retire the legacy item another account had adopted, logging out devices still on the previous release. The marker's accountID is now required to match. A first-time resolution that failed (launched offline, CloudKit unreachable) was never retried, disabling credentials for the rest of the process. resolve() now retries with backoff, and resolveIfNeeded() runs on foreground activation. Adoption re-checked nothing at execution time, so a scoped credential delivered by iCloud Keychain between rendering the offer and tapping it would be overwritten with the older legacy secret. Adoption now re-verifies eligibility and writes with a non-replacing add, leaving a duplicate strictly alone. Adds regression tests for the sibling-account deletion and the adoption race.
285 lines
10 KiB
Swift
285 lines
10 KiB
Swift
//
|
|
// AccountKeychainCredentials.swift
|
|
// Yattee
|
|
//
|
|
// Shared implementation of account-keyed, identity-scoped credential storage.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// The engine behind `AccountCredentialsManager`.
|
|
///
|
|
/// Both credential managers own one of these and forward to it, so the scoping
|
|
/// rules and the legacy-adoption protocol exist in exactly one place.
|
|
@MainActor
|
|
@Observable
|
|
final class AccountKeychainCredentials {
|
|
private let store: KeychainCredentialStore
|
|
private let identityScope: CredentialIdentityScope
|
|
private let markers: CredentialAdoptionMarkerStore
|
|
|
|
/// Name used in log lines ("SID", "token") so messages read naturally for
|
|
/// whichever backend owns this instance.
|
|
private let credentialNoun: String
|
|
|
|
/// Known logins, partitioned by the identity scope they were observed under.
|
|
///
|
|
/// Keyed rather than invalidated: a switch of Apple account (or the first
|
|
/// resolution completing) must never let one identity's cache answer for
|
|
/// another, and clearing a shared cache would mean mutating observable
|
|
/// state from inside read methods a view body can call.
|
|
private var loginsByScope: [String: Set<UUID>] = [:]
|
|
|
|
/// Accounts holding a credential under the current identity.
|
|
var loggedInAccountIDs: Set<UUID> {
|
|
guard let scope = identityScope.current else { return [] }
|
|
return loginsByScope[scope] ?? []
|
|
}
|
|
|
|
weak var settingsManager: SettingsManager?
|
|
|
|
init(
|
|
service: String,
|
|
credentialNoun: String,
|
|
identityScope: CredentialIdentityScope,
|
|
markers: CredentialAdoptionMarkerStore? = nil
|
|
) {
|
|
store = KeychainCredentialStore(service: service)
|
|
self.credentialNoun = credentialNoun
|
|
self.identityScope = identityScope
|
|
self.markers = markers ?? CredentialAdoptionMarkerStore()
|
|
}
|
|
|
|
var isScopeResolved: Bool { identityScope.isResolved }
|
|
|
|
/// Credentials sync to iCloud Keychain on the same toggle as the instances
|
|
/// they belong to.
|
|
private var shouldSyncToiCloud: Bool {
|
|
settingsManager?.iCloudSyncEnabled == true && settingsManager?.syncInstances == true
|
|
}
|
|
|
|
private func currentScope() -> String? {
|
|
identityScope.current
|
|
}
|
|
|
|
// MARK: - Credentials
|
|
|
|
func setCredential(_ credential: String, for account: Account) {
|
|
guard let scope = currentScope() else {
|
|
LoggingService.shared.error(
|
|
"Refusing to store \(credentialNoun) before the identity scope is resolved",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
return
|
|
}
|
|
|
|
let syncEnabled = shouldSyncToiCloud
|
|
let status = store.set(
|
|
credential,
|
|
forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id),
|
|
synchronizable: syncEnabled
|
|
)
|
|
|
|
guard status == errSecSuccess else {
|
|
LoggingService.shared.error(
|
|
"Failed to store \(credentialNoun)",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id), status=\(store.describe(status))"
|
|
)
|
|
return
|
|
}
|
|
|
|
loginsByScope[scope, default: []].insert(account.id)
|
|
|
|
LoggingService.shared.info(
|
|
"Stored \(credentialNoun)",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id), iCloudSync=\(syncEnabled)"
|
|
)
|
|
}
|
|
|
|
func credential(for account: Account) -> String? {
|
|
guard let scope = currentScope() else { return nil }
|
|
return store.value(
|
|
forKey: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id)
|
|
)
|
|
}
|
|
|
|
func deleteCredential(for account: Account) {
|
|
guard let scope = currentScope() else { return }
|
|
|
|
store.delete(key: KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id))
|
|
store.delete(key: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id))
|
|
loginsByScope[scope]?.remove(account.id)
|
|
|
|
LoggingService.shared.info(
|
|
"Deleted \(credentialNoun)",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
|
|
HomeInstanceCache.shared.clear(instanceID: account.instanceID, contentType: .feed)
|
|
}
|
|
|
|
// MARK: - Username
|
|
|
|
func setUsername(_ username: String, for account: Account) {
|
|
guard let scope = currentScope() else { return }
|
|
store.set(
|
|
username,
|
|
forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id),
|
|
synchronizable: shouldSyncToiCloud
|
|
)
|
|
}
|
|
|
|
func username(for account: Account) -> String? {
|
|
guard let scope = currentScope() else { return nil }
|
|
return store.value(
|
|
forKey: KeychainCredentialStore.usernameKey(scope: scope, accountID: account.id)
|
|
)
|
|
}
|
|
|
|
// MARK: - Status
|
|
|
|
func isLoggedIn(for account: Account) -> Bool {
|
|
guard let scope = currentScope() else { return false }
|
|
|
|
if loginsByScope[scope]?.contains(account.id) == true {
|
|
return true
|
|
}
|
|
|
|
let hasCredential = credential(for: account) != nil
|
|
if hasCredential {
|
|
loginsByScope[scope, default: []].insert(account.id)
|
|
}
|
|
return hasCredential
|
|
}
|
|
|
|
func refreshLoginStatus(for account: Account) {
|
|
guard let scope = currentScope() else { return }
|
|
|
|
if credential(for: account) != nil {
|
|
loginsByScope[scope, default: []].insert(account.id)
|
|
} else {
|
|
loginsByScope[scope]?.remove(account.id)
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy adoption
|
|
|
|
func hasAdoptableLegacyCredential(for account: Account) -> Bool {
|
|
guard let scope = currentScope() else { return false }
|
|
guard !markers.hasAdopted(instanceID: account.instanceID, scope: scope) else { return false }
|
|
guard credential(for: account) == nil else { return false }
|
|
|
|
return legacyCredential(for: account) != nil
|
|
}
|
|
|
|
/// Copies the legacy item into this account's scoped key.
|
|
///
|
|
/// Copies — never moves. Deleting a synchronizable item propagates to every
|
|
/// device signed in to the same Apple account, so removing the legacy item
|
|
/// here would log the user out of devices still running the previous
|
|
/// release, mid-rollout. It is retained until the account is really
|
|
/// deleted; general cleanup waits for a compatibility-breaking release.
|
|
@discardableResult
|
|
func adoptLegacyCredential(for account: Account) -> Bool {
|
|
guard let scope = currentScope() else { return false }
|
|
guard !markers.hasAdopted(instanceID: account.instanceID, scope: scope) else { return false }
|
|
guard let legacy = legacyCredential(for: account) else { return false }
|
|
|
|
// Eligibility is re-checked here, not just when the offer was rendered:
|
|
// iCloud Keychain may have delivered the real credential in between, and
|
|
// replacing it with the older legacy secret would be a silent downgrade.
|
|
guard credential(for: account) == nil else {
|
|
LoggingService.shared.info(
|
|
"Skipping legacy \(credentialNoun) adoption — a scoped credential already arrived",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
return false
|
|
}
|
|
|
|
let key = KeychainCredentialStore.credentialKey(scope: scope, accountID: account.id)
|
|
let status = store.addIfAbsent(legacy, forKey: key, synchronizable: shouldSyncToiCloud)
|
|
|
|
// A duplicate means a scoped credential landed between the check above
|
|
// and this write — leave it strictly alone, it is newer than the legacy
|
|
// secret.
|
|
guard status == errSecSuccess else {
|
|
LoggingService.shared.error(
|
|
"Legacy \(credentialNoun) adoption did not write",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id), status=\(store.describe(status))"
|
|
)
|
|
return false
|
|
}
|
|
|
|
// The marker is only written after the scoped copy reads back: a marker
|
|
// for a credential that never landed would suppress the adoption offer
|
|
// and strand the user with no way to sign in short of retyping.
|
|
guard store.value(forKey: key) == legacy else {
|
|
LoggingService.shared.error(
|
|
"Legacy \(credentialNoun) adoption failed verification",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id)"
|
|
)
|
|
// Safe to remove: this write is the item that just landed.
|
|
store.delete(key: key)
|
|
return false
|
|
}
|
|
|
|
markers.record(
|
|
instanceID: account.instanceID,
|
|
accountID: account.id,
|
|
scope: scope,
|
|
secret: legacy
|
|
)
|
|
loginsByScope[scope, default: []].insert(account.id)
|
|
|
|
LoggingService.shared.info(
|
|
"Adopted legacy \(credentialNoun) into the identity-scoped key",
|
|
category: .keychain,
|
|
details: "accountID=\(account.id), instanceID=\(account.instanceID)"
|
|
)
|
|
|
|
return true
|
|
}
|
|
|
|
func deleteLegacyCredentialIfOwned(for account: Account) {
|
|
guard let scope = currentScope() else { return }
|
|
|
|
let legacyKey = KeychainCredentialStore.legacyKey(instanceID: account.instanceID)
|
|
let currentValue = store.value(forKey: legacyKey)
|
|
|
|
guard markers.canDeleteLegacyItem(
|
|
instanceID: account.instanceID,
|
|
accountID: account.id,
|
|
scope: scope,
|
|
currentLegacyValue: currentValue
|
|
) else {
|
|
LoggingService.shared.info(
|
|
"Leaving the legacy \(credentialNoun) in place — not this account's to delete",
|
|
category: .keychain,
|
|
details: "instanceID=\(account.instanceID), accountID=\(account.id)"
|
|
)
|
|
return
|
|
}
|
|
|
|
store.delete(key: legacyKey)
|
|
markers.removeMarker(instanceID: account.instanceID, scope: scope)
|
|
|
|
LoggingService.shared.info(
|
|
"Deleted the legacy \(credentialNoun) alongside its account",
|
|
category: .keychain,
|
|
details: "instanceID=\(account.instanceID)"
|
|
)
|
|
}
|
|
|
|
/// Reads the unscoped, pre-revamp item. Only the adoption paths call this.
|
|
private func legacyCredential(for account: Account) -> String? {
|
|
store.value(forKey: KeychainCredentialStore.legacyKey(instanceID: account.instanceID))
|
|
}
|
|
}
|